├── .npmrc ├── server ├── utils │ ├── config.ts │ ├── trakt.ts │ ├── prisma.ts │ ├── nickname.ts │ ├── logger.ts │ ├── playerStatus.ts │ ├── challenge.ts │ ├── auth.ts │ └── metrics.ts ├── routes │ ├── healthcheck.ts │ ├── metrics │ │ ├── providers.put.ts │ │ ├── daily.ts │ │ ├── weekly.ts │ │ ├── monthly.ts │ │ ├── index.get.ts │ │ ├── captcha.post.ts │ │ └── providers.post.ts │ ├── index.ts │ ├── meta.ts │ ├── lists │ │ └── [id].get.ts │ ├── users │ │ ├── [id] │ │ │ ├── lists │ │ │ │ ├── index.get.ts │ │ │ │ ├── [listId].delete.ts │ │ │ │ ├── index.post.ts │ │ │ │ └── index.patch.ts │ │ │ ├── sessions.ts │ │ │ ├── group-order.ts │ │ │ ├── ratings.ts │ │ │ ├── bookmarks │ │ │ │ └── [tmdbid] │ │ │ │ │ └── index.ts │ │ │ ├── bookmarks.ts │ │ │ ├── index.ts │ │ │ ├── progress │ │ │ │ ├── [tmdb_id] │ │ │ │ │ └── index.ts │ │ │ │ └── import.ts │ │ │ ├── watch-history │ │ │ │ └── [tmdbid] │ │ │ │ │ └── index.ts │ │ │ ├── watch-history.ts │ │ │ ├── progress.ts │ │ │ └── settings.ts │ │ └── @me.ts │ ├── auth │ │ ├── register │ │ │ ├── start.ts │ │ │ └── complete.ts │ │ ├── login │ │ │ ├── start │ │ │ │ └── index.ts │ │ │ └── complete │ │ │ │ └── index.ts │ │ └── derive-public-key.post.ts │ ├── sessions │ │ └── [sid] │ │ │ └── index.ts │ ├── discover │ │ └── index.ts │ └── letterboxd │ │ └── index.get.ts ├── middleware │ ├── cors.ts │ └── metrics.ts ├── plugins │ └── metrics.ts ├── api │ ├── jobs │ │ └── run.post.ts │ └── player │ │ ├── status.post.ts │ │ ├── status.get.ts │ │ └── README.md └── tasks │ └── jobs │ └── clear-metrics │ ├── daily.ts │ ├── weekly.ts │ └── monthly.ts ├── .github ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.yml └── workflows │ └── pull-request-testing.yaml ├── tsconfig.json ├── prisma ├── migrations │ ├── 20250717172604_add_group_to_bookmarks │ │ └── migration.sql │ ├── 20250422205521_added_type_lists_field │ │ └── migration.sql │ ├── 20250310203903_add_ratings_column │ │ └── migration.sql │ ├── 20250310204055_change_ratings_default_to_array │ │ └── migration.sql │ ├── 20250422202708_added_public_lists_field │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20250929230057_add_favorite_episode_array_to_bookmarks │ │ └── migration.sql │ ├── 20251202212402_add_auto_resume_setting │ │ └── migration.sql │ ├── 20250722170032_group_array_required │ │ └── migration.sql │ ├── 20250731030214_add_user_group_order │ │ └── migration.sql │ ├── 20250606034630_add_user_preferences │ │ └── migration.sql │ ├── 20251023152211_add_missing_settings │ │ └── migration.sql │ ├── 20251119115438_rename_debrid_fields │ │ └── migration.sql │ ├── 20250403013111_added_lists │ │ └── migration.sql │ ├── 20251202212025_add_watch_history │ │ └── migration.sql │ ├── 20251117173240_add_nickname_to_users │ │ └── migration.sql │ └── 20250310013319_init │ │ └── migration.sql └── schema.prisma ├── prisma.config.ts ├── .gitignore ├── .prettierrc ├── nixpacks.toml ├── package.json ├── Dockerfile ├── .env.example ├── nitro.config.ts ├── docker-compose.yml ├── railpack.json ├── README.md ├── .eslintrc.json └── examples └── player-status-integration.ts /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /server/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json'; 2 | 3 | export { version }; 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: FifthWit 4 | ko_fi: fifthwit 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // https://nitro.unjs.io/guide/typescript 2 | { 3 | "extends": "./.nitro/types/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /server/routes/healthcheck.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(event => { 2 | return { 3 | message: ``, 4 | }; 5 | }); 6 | -------------------------------------------------------------------------------- /prisma/migrations/20250717172604_add_group_to_bookmarks/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "bookmarks" ADD COLUMN "group" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250422205521_added_type_lists_field/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "list_items" ADD COLUMN "type" VARCHAR(255); 3 | -------------------------------------------------------------------------------- /server/routes/metrics/providers.put.ts: -------------------------------------------------------------------------------- 1 | // Redirect to the POST handler which now supports both methods 2 | export { default } from './providers.post'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250310203903_add_ratings_column/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ADD COLUMN "ratings" JSONB NOT NULL DEFAULT '{}'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250310204055_change_ratings_default_to_array/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ALTER COLUMN "ratings" SET DEFAULT '[]'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250422202708_added_public_lists_field/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "lists" ADD COLUMN "public" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/migrations/20250929230057_add_favorite_episode_array_to_bookmarks/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "bookmarks" ADD COLUMN "favorite_episodes" TEXT[] DEFAULT ARRAY[]::TEXT[]; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20251202212402_add_auto_resume_setting/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "user_settings" ADD COLUMN "enable_auto_resume_on_playback_error" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { defineConfig, env } from 'prisma/config'; 3 | 4 | export default defineConfig({ 5 | datasource: { 6 | url: env('DATABASE_URL'), 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /server/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { version } from '~/utils/config'; 2 | export default defineEventHandler(event => { 3 | return { 4 | message: `Backend is working as expected (v${version})`, 5 | }; 6 | }); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .data 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | .vscode 9 | .metrics.json 10 | pnpm-lock.yaml 11 | .metrics_weekly.json 12 | .metrics_monthly.json 13 | .metrics_daily.json 14 | generated -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "printWidth": 100, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /nixpacks.toml: -------------------------------------------------------------------------------- 1 | [phases.setup] 2 | commands = [ 3 | "npx prisma generate", 4 | "npx prisma migrate deploy" 5 | ] 6 | 7 | [phases.install] 8 | commands = [ 9 | "npm install" 10 | ] 11 | 12 | [phases.build] 13 | commands = [ 14 | "npm run build" 15 | ] 16 | 17 | [start] 18 | cmd = "source .env && node .output/server/index.mjs" 19 | -------------------------------------------------------------------------------- /server/routes/meta.ts: -------------------------------------------------------------------------------- 1 | const meta = useRuntimeConfig().public.meta; 2 | export default defineEventHandler(event => { 3 | return { 4 | name: meta.name, 5 | description: meta.description, 6 | version: meta.version, 7 | hasCaptcha: meta.captcha === 'true', 8 | captchaClientKey: meta.captchaClientKey, 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /prisma/migrations/20250722170032_group_array_required/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The `group` column on the `bookmarks` table would be dropped and recreated. This will lead to data loss if there is data in the column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "bookmarks" DROP COLUMN "group", 9 | ADD COLUMN "group" TEXT[]; 10 | -------------------------------------------------------------------------------- /server/utils/trakt.ts: -------------------------------------------------------------------------------- 1 | import Trakt from 'trakt.tv'; 2 | const traktKeys = useRuntimeConfig().trakt; 3 | 4 | let trakt: Trakt | null = null; 5 | 6 | if (traktKeys?.clientId && traktKeys?.clientSecret) { 7 | const options = { 8 | client_id: traktKeys.clientId, 9 | client_secret: traktKeys.clientSecret, 10 | }; 11 | trakt = new Trakt(options); 12 | } 13 | 14 | export default trakt; 15 | -------------------------------------------------------------------------------- /server/middleware/cors.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(event => { 2 | setResponseHeaders(event, { 3 | 'Access-Control-Allow-Origin': '*', 4 | 'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE', 5 | 'Access-Control-Allow-Headers': '*', 6 | }); 7 | 8 | if (event.method === 'OPTIONS') { 9 | event.node.res.statusCode = 204; 10 | return ''; 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /server/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaPg } from '@prisma/adapter-pg'; 2 | import { PrismaClient } from '../../generated/client'; 3 | 4 | const adapter = new PrismaPg({ 5 | connectionString: process.env.DATABASE_URL, 6 | }); 7 | 8 | const globalForPrisma = globalThis as unknown as { 9 | prisma: PrismaClient | undefined; 10 | }; 11 | 12 | export const prisma = new PrismaClient({ adapter }); 13 | 14 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] Documentation update 13 | -------------------------------------------------------------------------------- /server/routes/lists/[id].get.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '#imports'; 2 | 3 | export default defineEventHandler(async event => { 4 | const id = event.context.params?.id; 5 | const listInfo = await prisma.lists.findUnique({ 6 | where: { 7 | id: id, 8 | }, 9 | include: { 10 | list_items: true, 11 | }, 12 | }); 13 | 14 | if (!listInfo.public) { 15 | return createError({ 16 | statusCode: 403, 17 | message: 'List is not public', 18 | }); 19 | } 20 | 21 | return listInfo; 22 | }); 23 | -------------------------------------------------------------------------------- /prisma/migrations/20250731030214_add_user_group_order/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "user_group_order" ( 3 | "id" UUID NOT NULL, 4 | "user_id" VARCHAR(255) NOT NULL, 5 | "group_order" TEXT[] DEFAULT ARRAY[]::TEXT[], 6 | "created_at" TIMESTAMPTZ(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updated_at" TIMESTAMPTZ(0) NOT NULL, 8 | 9 | CONSTRAINT "user_group_order_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "user_group_order_user_id_unique" ON "user_group_order"("user_id"); 14 | -------------------------------------------------------------------------------- /server/plugins/metrics.ts: -------------------------------------------------------------------------------- 1 | import { defineNitroPlugin } from '#imports'; 2 | import { initializeAllMetrics } from '../utils/metrics'; 3 | import { scopedLogger } from '../utils/logger'; 4 | 5 | const log = scopedLogger('metrics-plugin'); 6 | 7 | export default defineNitroPlugin(async () => { 8 | try { 9 | log.info('Initializing metrics at startup...'); 10 | await initializeAllMetrics(); 11 | log.info('Metrics initialized.'); 12 | } catch (error) { 13 | log.error('Failed to initialize metrics at startup', { 14 | error: error instanceof Error ? error.message : String(error), 15 | }); 16 | } 17 | }); 18 | 19 | 20 | -------------------------------------------------------------------------------- /server/routes/users/[id]/lists/index.get.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '#imports'; 2 | import { prisma } from '#imports'; 3 | 4 | export default defineEventHandler(async event => { 5 | const userId = event.context.params?.id; 6 | const session = await useAuth().getCurrentSession(); 7 | 8 | if (session.user !== userId) { 9 | throw createError({ 10 | statusCode: 403, 11 | message: 'Cannot access other user information', 12 | }); 13 | } 14 | 15 | const lists = await prisma.lists.findMany({ 16 | where: { 17 | user_id: userId, 18 | }, 19 | include: { 20 | list_items: true, 21 | }, 22 | }); 23 | 24 | return { 25 | lists, 26 | }; 27 | }); 28 | -------------------------------------------------------------------------------- /server/routes/auth/register/start.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { useChallenge } from '~/utils/challenge'; 3 | 4 | const startSchema = z.object({ 5 | captchaToken: z.string().optional(), 6 | }); 7 | 8 | export default defineEventHandler(async event => { 9 | const body = await readBody(event); 10 | 11 | const result = startSchema.safeParse(body); 12 | if (!result.success) { 13 | throw createError({ 14 | statusCode: 400, 15 | message: 'Invalid request body', 16 | }); 17 | } 18 | 19 | const challenge = useChallenge(); 20 | const challengeCode = await challenge.createChallengeCode('registration', 'mnemonic'); 21 | 22 | return { 23 | challenge: challengeCode.code, 24 | }; 25 | }); 26 | -------------------------------------------------------------------------------- /server/routes/users/[id]/sessions.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | 3 | export default defineEventHandler(async event => { 4 | const userId = getRouterParam(event, 'id'); 5 | 6 | const session = await useAuth().getCurrentSession(); 7 | 8 | if (session.user !== userId) { 9 | throw createError({ 10 | statusCode: 403, 11 | message: 'Cannot access sessions for other users', 12 | }); 13 | } 14 | 15 | const sessions = await prisma.sessions.findMany({ 16 | where: { user: userId }, 17 | }); 18 | 19 | return sessions.map(s => ({ 20 | id: s.id, 21 | userId: s.user, 22 | createdAt: s.created_at.toISOString(), 23 | accessedAt: s.accessed_at.toISOString(), 24 | device: s.device, 25 | userAgent: s.user_agent, 26 | })); 27 | }); 28 | -------------------------------------------------------------------------------- /prisma/migrations/20250606034630_add_user_preferences/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "user_settings" ADD COLUMN "enable_autoplay" BOOLEAN NOT NULL DEFAULT true, 3 | ADD COLUMN "enable_carousel_view" BOOLEAN NOT NULL DEFAULT false, 4 | ADD COLUMN "enable_details_modal" BOOLEAN NOT NULL DEFAULT false, 5 | ADD COLUMN "enable_discover" BOOLEAN NOT NULL DEFAULT true, 6 | ADD COLUMN "enable_featured" BOOLEAN NOT NULL DEFAULT false, 7 | ADD COLUMN "enable_image_logos" BOOLEAN NOT NULL DEFAULT true, 8 | ADD COLUMN "enable_skip_credits" BOOLEAN NOT NULL DEFAULT true, 9 | ADD COLUMN "enable_source_order" BOOLEAN NOT NULL DEFAULT false, 10 | ADD COLUMN "enable_thumbnails" BOOLEAN NOT NULL DEFAULT false, 11 | ADD COLUMN "proxy_tmdb" BOOLEAN NOT NULL DEFAULT false, 12 | ADD COLUMN "source_order" TEXT[] DEFAULT ARRAY[]::TEXT[]; 13 | -------------------------------------------------------------------------------- /server/routes/metrics/daily.ts: -------------------------------------------------------------------------------- 1 | import { getRegistry } from '../../utils/metrics'; 2 | import { scopedLogger } from '../../utils/logger'; 3 | 4 | const log = scopedLogger('metrics-daily-endpoint'); 5 | 6 | export default defineEventHandler(async event => { 7 | try { 8 | // Get the daily registry 9 | const dailyRegistry = getRegistry('daily'); 10 | 11 | const metrics = await dailyRegistry.metrics(); 12 | event.node.res.setHeader('Content-Type', dailyRegistry.contentType); 13 | return metrics; 14 | } catch (error) { 15 | log.error('Error in daily metrics endpoint:', { 16 | evt: 'metrics_error', 17 | error: error instanceof Error ? error.message : String(error), 18 | }); 19 | throw createError({ 20 | statusCode: 500, 21 | message: error instanceof Error ? error.message : 'Failed to collect daily metrics', 22 | }); 23 | } 24 | }); -------------------------------------------------------------------------------- /server/routes/metrics/weekly.ts: -------------------------------------------------------------------------------- 1 | import { getRegistry } from '../../utils/metrics'; 2 | import { scopedLogger } from '../../utils/logger'; 3 | 4 | const log = scopedLogger('metrics-weekly-endpoint'); 5 | 6 | export default defineEventHandler(async event => { 7 | try { 8 | // Get the weekly registry 9 | const weeklyRegistry = getRegistry('weekly'); 10 | 11 | const metrics = await weeklyRegistry.metrics(); 12 | event.node.res.setHeader('Content-Type', weeklyRegistry.contentType); 13 | return metrics; 14 | } catch (error) { 15 | log.error('Error in weekly metrics endpoint:', { 16 | evt: 'metrics_error', 17 | error: error instanceof Error ? error.message : String(error), 18 | }); 19 | throw createError({ 20 | statusCode: 500, 21 | message: error instanceof Error ? error.message : 'Failed to collect weekly metrics', 22 | }); 23 | } 24 | }); -------------------------------------------------------------------------------- /server/routes/metrics/monthly.ts: -------------------------------------------------------------------------------- 1 | import { getRegistry } from '../../utils/metrics'; 2 | import { scopedLogger } from '../../utils/logger'; 3 | 4 | const log = scopedLogger('metrics-monthly-endpoint'); 5 | 6 | export default defineEventHandler(async event => { 7 | try { 8 | // Get the monthly registry 9 | const monthlyRegistry = getRegistry('monthly'); 10 | 11 | const metrics = await monthlyRegistry.metrics(); 12 | event.node.res.setHeader('Content-Type', monthlyRegistry.contentType); 13 | return metrics; 14 | } catch (error) { 15 | log.error('Error in monthly metrics endpoint:', { 16 | evt: 'metrics_error', 17 | error: error instanceof Error ? error.message : String(error), 18 | }); 19 | throw createError({ 20 | statusCode: 500, 21 | message: error instanceof Error ? error.message : 'Failed to collect monthly metrics', 22 | }); 23 | } 24 | }); -------------------------------------------------------------------------------- /server/api/jobs/run.post.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, getQuery, readBody, createError } from 'h3'; 2 | import { runTask } from '#imports'; 3 | 4 | export default defineEventHandler(async (event) => { 5 | // Get job name from query parameters 6 | const query = getQuery(event); 7 | const jobName = query.job as string; 8 | 9 | if (!jobName) { 10 | throw createError({ 11 | statusCode: 400, 12 | statusMessage: "Missing job parameter" 13 | }); 14 | } 15 | 16 | try { 17 | // Run the specified task 18 | const result = await runTask(jobName, { 19 | payload: await readBody(event).catch(() => ({})) 20 | }); 21 | 22 | return { 23 | success: true, 24 | job: jobName, 25 | result 26 | }; 27 | } catch (error) { 28 | throw createError({ 29 | statusCode: 500, 30 | statusMessage: `Failed to run job: ${error.message || 'Unknown error'}` 31 | }); 32 | } 33 | }); -------------------------------------------------------------------------------- /prisma/migrations/20251023152211_add_missing_settings/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "user_settings" ADD COLUMN "disabled_embeds" TEXT[] DEFAULT ARRAY[]::TEXT[], 3 | ADD COLUMN "disabled_sources" TEXT[] DEFAULT ARRAY[]::TEXT[], 4 | ADD COLUMN "embed_order" TEXT[] DEFAULT ARRAY[]::TEXT[], 5 | ADD COLUMN "enable_double_click_to_seek" BOOLEAN NOT NULL DEFAULT false, 6 | ADD COLUMN "enable_embed_order" BOOLEAN NOT NULL DEFAULT false, 7 | ADD COLUMN "enable_hold_to_boost" BOOLEAN NOT NULL DEFAULT false, 8 | ADD COLUMN "enable_low_performance_mode" BOOLEAN NOT NULL DEFAULT false, 9 | ADD COLUMN "enable_native_subtitles" BOOLEAN NOT NULL DEFAULT false, 10 | ADD COLUMN "force_compact_episode_view" BOOLEAN NOT NULL DEFAULT false, 11 | ADD COLUMN "home_section_order" TEXT[] DEFAULT ARRAY[]::TEXT[], 12 | ADD COLUMN "manual_source_selection" BOOLEAN NOT NULL DEFAULT false, 13 | ADD COLUMN "real_debrid_key" VARCHAR(255); 14 | -------------------------------------------------------------------------------- /server/routes/auth/login/start/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { useChallenge } from '~/utils/challenge'; 3 | 4 | const startSchema = z.object({ 5 | publicKey: z.string(), 6 | }); 7 | 8 | export default defineEventHandler(async event => { 9 | const body = await readBody(event); 10 | 11 | const result = startSchema.safeParse(body); 12 | if (!result.success) { 13 | throw createError({ 14 | statusCode: 400, 15 | message: 'Invalid request body', 16 | }); 17 | } 18 | 19 | const user = await prisma.users.findUnique({ 20 | where: { public_key: body.publicKey }, 21 | }); 22 | 23 | if (!user) { 24 | throw createError({ 25 | statusCode: 401, 26 | message: 'User cannot be found', 27 | }); 28 | } 29 | 30 | const challenge = useChallenge(); 31 | const challengeCode = await challenge.createChallengeCode('login', 'mnemonic'); 32 | 33 | return { 34 | challenge: challengeCode.code, 35 | }; 36 | }); 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "2.1.3", 4 | "scripts": { 5 | "build": "nitro build", 6 | "dev": "nitro dev", 7 | "prepare": "nitro prepare", 8 | "preview": "node .output/server/index.mjs" 9 | }, 10 | "devDependencies": { 11 | "@typescript-eslint/eslint-plugin": "^8.31.1", 12 | "@typescript-eslint/parser": "^8.31.1", 13 | "eslint": "^9.26.0", 14 | "eslint-config-prettier": "^10.1.2", 15 | "eslint-plugin-prettier": "^5.4.0", 16 | "nitropack": "latest", 17 | "prettier": "^3.5.3", 18 | "prisma": "^7.0.1" 19 | }, 20 | "dependencies": { 21 | "@prisma/adapter-pg": "^7.0.1", 22 | "@prisma/client": "^7.0.1", 23 | "cheerio": "^1.0.0", 24 | "dotenv": "^16.4.7", 25 | "jsonwebtoken": "^9.0.2", 26 | "prom-client": "^15.1.3", 27 | "tmdb-ts": "^2.0.1", 28 | "trakt.tv": "^8.2.0", 29 | "tweetnacl": "^1.0.3", 30 | "whatwg-url": "^14.2.0", 31 | "zod": "^3.24.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /prisma/migrations/20251119115438_rename_debrid_fields/migration.sql: -------------------------------------------------------------------------------- 1 | -- Safely add the new 'debrid_service' column if it doesn't exist yet 2 | ALTER TABLE "user_settings" ADD COLUMN IF NOT EXISTS "debrid_service" VARCHAR(255); 3 | 4 | -- Conditionally update existing rows to set the default service to 'realdebrid' 5 | -- only if the 'debrid_token' column actually exists. 6 | DO $$ 7 | BEGIN 8 | IF EXISTS ( 9 | SELECT 1 10 | FROM information_schema.columns 11 | WHERE table_schema = 'public' 12 | AND table_name = 'user_settings' 13 | AND column_name = 'debrid_token' 14 | ) THEN 15 | -- Use EXECUTE so the UPDATE is not parsed at compile time (avoids errors 16 | -- if the column is missing at parse time in some PostgreSQL versions) 17 | EXECUTE 'UPDATE "user_settings" 18 | SET "debrid_service" = ''realdebrid'' 19 | WHERE "debrid_token" IS NOT NULL'; 20 | END IF; 21 | END $$; 22 | -------------------------------------------------------------------------------- /server/routes/users/@me.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | 3 | export default defineEventHandler(async event => { 4 | const session = await useAuth().getCurrentSession(); 5 | 6 | const user = await prisma.users.findUnique({ 7 | where: { id: session.user }, 8 | }); 9 | 10 | if (!user) { 11 | throw createError({ 12 | statusCode: 404, 13 | message: 'User not found', 14 | }); 15 | } 16 | 17 | return { 18 | user: { 19 | id: user.id, 20 | publicKey: user.public_key, 21 | namespace: user.namespace, 22 | nickname: user.nickname, 23 | profile: user.profile, 24 | permissions: user.permissions, 25 | }, 26 | session: { 27 | id: session.id, 28 | user: session.user, 29 | createdAt: session.created_at, 30 | accessedAt: session.accessed_at, 31 | expiresAt: session.expires_at, 32 | device: session.device, 33 | userAgent: session.user_agent, 34 | }, 35 | }; 36 | }); 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm ci 8 | 9 | ARG DATABASE_URL 10 | ARG DATABASE_URL_DOCKER 11 | ARG META_NAME 12 | ARG META_DESCRIPTION 13 | ARG CRYPTO_SECRET 14 | ARG TMDB_API_KEY 15 | ARG CAPTCHA=false 16 | ARG CAPTCHA_CLIENT_KEY 17 | ARG TRAKT_CLIENT_ID 18 | ARG TRAKT_SECRET_ID 19 | ARG NODE_ENV=production 20 | 21 | ENV DATABASE_URL=${DATABASE_URL} 22 | ENV DATABASE_URL_DOCKER=${DATABASE_URL_DOCKER} 23 | ENV META_NAME=${META_NAME} 24 | ENV META_DESCRIPTION=${META_DESCRIPTION} 25 | ENV CRYPTO_SECRET=${CRYPTO_SECRET} 26 | ENV TMDB_API_KEY=${TMDB_API_KEY} 27 | ENV CAPTCHA=${CAPTCHA} 28 | ENV CAPTCHA_CLIENT_KEY=${CAPTCHA_CLIENT_KEY} 29 | ENV TRAKT_CLIENT_ID=${TRAKT_CLIENT_ID} 30 | ENV TRAKT_SECRET_ID=${TRAKT_SECRET_ID} 31 | ENV NODE_ENV=${NODE_ENV} 32 | 33 | COPY . . 34 | 35 | RUN npx prisma generate 36 | 37 | RUN npm run build 38 | 39 | EXPOSE 3000 40 | 41 | CMD ["sh", "-c", "npx prisma migrate deploy && node .output/server/index.mjs"] 42 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database credentials 2 | PG_USER=p-stream_user 3 | # Use a strong password 4 | PG_PASSWORD=password 5 | PG_DB=p-stream_backend 6 | DATABASE_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:5432/${PG_DB}?schema=public" 7 | DATABASE_URL_DOCKER="postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}?schema=public" 8 | 9 | # App metadata 10 | META_NAME='' 11 | META_DESCRIPTION='' 12 | 13 | # Required: Security secret (generate with `openssl rand -base64 24` or use https://bitwarden.com/password-generator/) 14 | CRYPTO_SECRET='' 15 | 16 | # API Keys 17 | # From https://www.themoviedb.org/settings/api 18 | TMDB_API_KEY='' 19 | # From https://trakt.tv/oauth/applications 20 | # Click New Application after you've logged in, enter the name of the app, which doesnt matter, and for redirect url, just do https://google.com, it doesnt matter 21 | # Now it will show you those keys 22 | TRAKT_CLIENT_ID='' 23 | TRAKT_SECRET_ID='' 24 | 25 | # Optional: Captcha 26 | CAPTCHA=false 27 | CAPTCHA_CLIENT_KEY='' 28 | -------------------------------------------------------------------------------- /prisma/migrations/20250403013111_added_lists/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "lists" ( 3 | "id" UUID NOT NULL, 4 | "user_id" VARCHAR(255) NOT NULL, 5 | "name" VARCHAR(255) NOT NULL, 6 | "description" VARCHAR(255), 7 | "created_at" TIMESTAMPTZ(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updated_at" TIMESTAMPTZ(0) NOT NULL, 9 | 10 | CONSTRAINT "lists_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "list_items" ( 15 | "id" UUID NOT NULL, 16 | "list_id" UUID NOT NULL, 17 | "tmdb_id" VARCHAR(255) NOT NULL, 18 | "added_at" TIMESTAMPTZ(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 | 20 | CONSTRAINT "list_items_pkey" PRIMARY KEY ("id") 21 | ); 22 | 23 | -- CreateIndex 24 | CREATE INDEX "lists_user_id_index" ON "lists"("user_id"); 25 | 26 | -- CreateIndex 27 | CREATE UNIQUE INDEX "list_items_list_id_tmdb_id_unique" ON "list_items"("list_id", "tmdb_id"); 28 | 29 | -- AddForeignKey 30 | ALTER TABLE "list_items" ADD CONSTRAINT "list_items_list_id_fkey" FOREIGN KEY ("list_id") REFERENCES "lists"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 31 | -------------------------------------------------------------------------------- /nitro.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | config(); 3 | import { version } from './server/utils/config'; 4 | //https://nitro.unjs.io/config 5 | export default defineNitroConfig({ 6 | srcDir: 'server', 7 | compatibilityDate: '2025-03-05', 8 | experimental: { 9 | asyncContext: true, 10 | tasks: true, 11 | }, 12 | scheduledTasks: { 13 | // Daily cron jobs (midnight) 14 | '0 0 * * *': ['jobs:clear-metrics:daily'], 15 | // Weekly cron jobs (Sunday midnight) 16 | '0 0 * * 0': ['jobs:clear-metrics:weekly'], 17 | // Monthly cron jobs (1st of month at midnight) 18 | '0 0 1 * *': ['jobs:clear-metrics:monthly'] 19 | }, 20 | runtimeConfig: { 21 | public: { 22 | meta: { 23 | name: process.env.META_NAME || '', 24 | description: process.env.META_DESCRIPTION || '', 25 | version: version || '', 26 | captcha: (process.env.CAPTCHA === 'true').toString(), 27 | captchaClientKey: process.env.CAPTCHA_CLIENT_KEY || '', 28 | }, 29 | }, 30 | cryptoSecret: process.env.CRYPTO_SECRET, 31 | tmdbApiKey: process.env.TMDB_API_KEY, 32 | trakt: { 33 | clientId: process.env.TRAKT_CLIENT_ID, 34 | clientSecret: process.env.TRAKT_SECRET_ID, 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /prisma/migrations/20251202212025_add_watch_history/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `real_debrid_key` on the `user_settings` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "user_settings" DROP COLUMN "real_debrid_key", 9 | ADD COLUMN "debrid_token" VARCHAR(255); 10 | 11 | -- AlterTable 12 | ALTER TABLE "users" ALTER COLUMN "nickname" DROP DEFAULT; 13 | 14 | -- CreateTable 15 | CREATE TABLE "watch_history" ( 16 | "id" UUID NOT NULL, 17 | "user_id" VARCHAR(255) NOT NULL, 18 | "tmdb_id" VARCHAR(255) NOT NULL, 19 | "season_id" VARCHAR(255), 20 | "episode_id" VARCHAR(255), 21 | "meta" JSONB NOT NULL, 22 | "duration" BIGINT NOT NULL, 23 | "watched" BIGINT NOT NULL, 24 | "watched_at" TIMESTAMPTZ(0) NOT NULL, 25 | "completed" BOOLEAN NOT NULL DEFAULT false, 26 | "season_number" INTEGER, 27 | "episode_number" INTEGER, 28 | "updated_at" TIMESTAMPTZ(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | 30 | CONSTRAINT "watch_history_pkey" PRIMARY KEY ("id") 31 | ); 32 | 33 | -- CreateIndex 34 | CREATE UNIQUE INDEX "watch_history_tmdb_id_user_id_season_id_episode_id_unique" ON "watch_history"("tmdb_id", "user_id", "season_id", "episode_id"); 35 | -------------------------------------------------------------------------------- /server/routes/users/[id]/lists/[listId].delete.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '#imports'; 2 | import { prisma } from '#imports'; 3 | 4 | export default defineEventHandler(async event => { 5 | const userId = event.context.params?.id; 6 | const listId = event.context.params?.listId; 7 | const session = await useAuth().getCurrentSession(); 8 | 9 | if (session.user !== userId) { 10 | throw createError({ 11 | statusCode: 403, 12 | message: 'Cannot delete lists for other users', 13 | }); 14 | } 15 | const list = await prisma.lists.findUnique({ 16 | where: { id: listId }, 17 | }); 18 | 19 | if (!list) { 20 | throw createError({ 21 | statusCode: 404, 22 | message: 'List not found', 23 | }); 24 | } 25 | 26 | if (list.user_id !== userId) { 27 | throw createError({ 28 | statusCode: 403, 29 | message: "Cannot delete lists you don't own", 30 | }); 31 | } 32 | 33 | await prisma.$transaction(async tx => { 34 | await tx.list_items.deleteMany({ 35 | where: { list_id: listId }, 36 | }); 37 | 38 | await tx.lists.delete({ 39 | where: { id: listId }, 40 | }); 41 | }); 42 | 43 | return { 44 | id: listId, 45 | message: 'List deleted successfully', 46 | }; 47 | }); 48 | -------------------------------------------------------------------------------- /server/utils/nickname.ts: -------------------------------------------------------------------------------- 1 | const adjectives = [ 2 | 'Cool', 'Swift', 'Bright', 'Silent', 'Quick', 'Brave', 'Clever', 'Wise', 'Bold', 'Calm', 3 | 'Wild', 'Free', 'Sharp', 'Soft', 'Hard', 'Light', 'Dark', 'Fast', 'Slow', 'High', 4 | 'Low', 'Deep', 'Shallow', 'Wide', 'Narrow', 'Long', 'Short', 'Big', 'Small', 'Hot', 5 | 'Cold', 'Warm', 'Wet', 'Dry', 'Sweet', 'Sour', 'Bitter', 'Salty', 'Fresh', 'Stale', 6 | 'New', 'Old', 'Young', 'Mature', 'Happy', 'Sad', 'Angry', 'Calm', 'Excited', 'Bored' 7 | ]; 8 | 9 | const nouns = [ 10 | 'Eagle', 'Wolf', 'Bear', 'Lion', 'Tiger', 'Shark', 'Falcon', 'Hawk', 'Owl', 'Raven', 11 | 'Fox', 'Cat', 'Dog', 'Horse', 'Deer', 'Rabbit', 'Mouse', 'Bird', 'Fish', 'Snake', 12 | 'Turtle', 'Frog', 'Butterfly', 'Bee', 'Ant', 'Spider', 'Dragon', 'Phoenix', 'Unicorn', 13 | 'Griffin', 'Wizard', 'Knight', 'Warrior', 'Hunter', 'Explorer', 'Adventurer', 'Pirate', 14 | 'Ninja', 'Samurai', 'Ranger', 'Scout', 'Pilot', 'Captain', 'Commander', 'Leader', 15 | 'Hero', 'Champion', 'Master', 'Legend' 16 | ]; 17 | 18 | export function generateRandomNickname(): string { 19 | const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; 20 | const noun = nouns[Math.floor(Math.random() * nouns.length)]; 21 | return `${adjective} ${noun}`; 22 | } 23 | -------------------------------------------------------------------------------- /server/routes/metrics/index.get.ts: -------------------------------------------------------------------------------- 1 | import { register } from 'prom-client'; 2 | import { setupMetrics, initializeAllMetrics } from '../../utils/metrics'; 3 | import { scopedLogger } from '../../utils/logger'; 4 | 5 | const log = scopedLogger('metrics-endpoint'); 6 | 7 | let isInitialized = false; 8 | 9 | async function ensureMetricsInitialized() { 10 | if (!isInitialized) { 11 | log.info('Initializing metrics from endpoint...', { evt: 'init_start' }); 12 | await initializeAllMetrics(); 13 | isInitialized = true; 14 | log.info('Metrics initialized from endpoint', { evt: 'init_complete' }); 15 | } 16 | } 17 | 18 | export default defineEventHandler(async event => { 19 | try { 20 | await ensureMetricsInitialized(); 21 | // Use the default registry (all-time metrics) 22 | const metrics = await register.metrics(); 23 | event.node.res.setHeader('Content-Type', register.contentType); 24 | return metrics; 25 | } catch (error) { 26 | log.error('Error in metrics endpoint:', { 27 | evt: 'metrics_error', 28 | error: error instanceof Error ? error.message : String(error), 29 | }); 30 | throw createError({ 31 | statusCode: 500, 32 | message: error instanceof Error ? error.message : 'Failed to collect metrics', 33 | }); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /prisma/migrations/20251117173240_add_nickname_to_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- Add nickname column to users table 2 | ALTER TABLE "users" ADD COLUMN "nickname" VARCHAR(255) NOT NULL DEFAULT ''; 3 | 4 | -- Generate random nicknames for existing users 5 | UPDATE "users" SET "nickname" = ( 6 | SELECT 7 | adj || ' ' || noun 8 | FROM ( 9 | SELECT 10 | unnest(ARRAY['Cool', 'Swift', 'Bright', 'Silent', 'Quick', 'Brave', 'Clever', 'Wise', 'Bold', 'Calm', 'Wild', 'Free', 'Sharp', 'Soft', 'Hard', 'Light', 'Dark', 'Fast', 'Slow', 'High', 'Low', 'Deep', 'Shallow', 'Wide', 'Narrow', 'Long', 'Short', 'Big', 'Small', 'Hot', 'Cold', 'Warm', 'Wet', 'Dry', 'Sweet', 'Sour', 'Bitter', 'Salty', 'Fresh', 'Stale', 'New', 'Old', 'Young', 'Mature', 'Happy', 'Sad', 'Angry', 'Calm', 'Excited', 'Bored']) AS adj, 11 | unnest(ARRAY['Eagle', 'Wolf', 'Bear', 'Lion', 'Tiger', 'Shark', 'Falcon', 'Hawk', 'Owl', 'Raven', 'Fox', 'Cat', 'Dog', 'Horse', 'Deer', 'Rabbit', 'Mouse', 'Bird', 'Fish', 'Snake', 'Turtle', 'Frog', 'Butterfly', 'Bee', 'Ant', 'Spider', 'Dragon', 'Phoenix', 'Unicorn', 'Griffin', 'Wizard', 'Knight', 'Warrior', 'Hunter', 'Explorer', 'Adventurer', 'Pirate', 'Ninja', 'Samurai', 'Ranger', 'Scout', 'Pilot', 'Captain', 'Commander', 'Leader', 'Hero', 'Champion', 'Master', 'Legend']) AS noun 12 | ORDER BY random() 13 | LIMIT 1 14 | ) AS random_nick 15 | ); 16 | -------------------------------------------------------------------------------- /server/utils/logger.ts: -------------------------------------------------------------------------------- 1 | type LogLevel = 'info' | 'warn' | 'error' | 'debug'; 2 | 3 | interface LogContext { 4 | evt?: string; 5 | [key: string]: any; 6 | } 7 | 8 | interface Logger { 9 | info(message: string, context?: LogContext): void; 10 | warn(message: string, context?: LogContext): void; 11 | error(message: string, context?: LogContext): void; 12 | debug(message: string, context?: LogContext): void; 13 | } 14 | 15 | function createLogger(scope: string): Logger { 16 | const log = (level: LogLevel, message: string, context?: LogContext) => { 17 | const timestamp = new Date().toISOString(); 18 | const logData = { 19 | timestamp, 20 | level, 21 | scope, 22 | message, 23 | ...context, 24 | }; 25 | 26 | if (process.env.NODE_ENV === 'production') { 27 | console.log(JSON.stringify(logData)); 28 | } 29 | }; 30 | 31 | return { 32 | info: (message: string, context?: LogContext) => log('info', message, context), 33 | warn: (message: string, context?: LogContext) => log('warn', message, context), 34 | error: (message: string, context?: LogContext) => log('error', message, context), 35 | debug: (message: string, context?: LogContext) => log('debug', message, context), 36 | }; 37 | } 38 | 39 | export function scopedLogger(scope: string): Logger { 40 | return createLogger(scope); 41 | } 42 | -------------------------------------------------------------------------------- /server/tasks/jobs/clear-metrics/daily.ts: -------------------------------------------------------------------------------- 1 | import { defineTask } from '#imports'; 2 | import { scopedLogger } from '../../../utils/logger'; 3 | import { setupMetrics } from '../../../utils/metrics'; 4 | 5 | const logger = scopedLogger('tasks:clear-metrics:daily'); 6 | 7 | export default defineTask({ 8 | meta: { 9 | name: "jobs:clear-metrics:daily", 10 | description: "Clear daily metrics at midnight", 11 | }, 12 | async run() { 13 | logger.info("Clearing daily metrics"); 14 | const startTime = Date.now(); 15 | 16 | try { 17 | // Clear and reinitialize daily metrics 18 | await setupMetrics('daily', true); 19 | 20 | const executionTime = Date.now() - startTime; 21 | logger.info(`Daily metrics cleared in ${executionTime}ms`); 22 | 23 | return { 24 | result: { 25 | status: "success", 26 | message: "Successfully cleared daily metrics", 27 | executionTimeMs: executionTime, 28 | timestamp: new Date().toISOString() 29 | } 30 | }; 31 | } catch (error) { 32 | logger.error("Error clearing daily metrics:", { error: error.message }); 33 | return { 34 | result: { 35 | status: "error", 36 | message: error.message || "An error occurred clearing daily metrics", 37 | executionTimeMs: Date.now() - startTime, 38 | timestamp: new Date().toISOString() 39 | } 40 | }; 41 | } 42 | }, 43 | }); -------------------------------------------------------------------------------- /server/routes/metrics/captcha.post.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { getMetrics, recordCaptchaMetrics } from '~/utils/metrics'; 3 | import { scopedLogger } from '~/utils/logger'; 4 | import { setupMetrics } from '~/utils/metrics'; 5 | 6 | const log = scopedLogger('metrics-captcha'); 7 | 8 | let isInitialized = false; 9 | 10 | async function ensureMetricsInitialized() { 11 | if (!isInitialized) { 12 | log.info('Initializing metrics from captcha endpoint...', { evt: 'init_start' }); 13 | await setupMetrics(); 14 | isInitialized = true; 15 | log.info('Metrics initialized from captcha endpoint', { evt: 'init_complete' }); 16 | } 17 | } 18 | 19 | export default defineEventHandler(async event => { 20 | try { 21 | await ensureMetricsInitialized(); 22 | 23 | const body = await readBody(event); 24 | const validatedBody = z 25 | .object({ 26 | success: z.boolean(), 27 | }) 28 | .parse(body); 29 | 30 | recordCaptchaMetrics(validatedBody.success); 31 | 32 | return true; 33 | } catch (error) { 34 | log.error('Failed to process captcha metrics', { 35 | evt: 'metrics_error', 36 | error: error instanceof Error ? error.message : String(error), 37 | }); 38 | throw createError({ 39 | statusCode: error instanceof Error && error.message === 'metrics not initialized' ? 503 : 400, 40 | message: error instanceof Error ? error.message : 'Failed to process metrics', 41 | }); 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /server/routes/users/[id]/group-order.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | 4 | const groupOrderSchema = z.array(z.string()); 5 | 6 | export default defineEventHandler(async event => { 7 | const userId = event.context.params?.id; 8 | const method = event.method; 9 | 10 | const session = await useAuth().getCurrentSession(); 11 | 12 | if (session.user !== userId) { 13 | throw createError({ 14 | statusCode: 403, 15 | message: 'Cannot access other user information', 16 | }); 17 | } 18 | 19 | if (method === 'GET') { 20 | const groupOrder = await prisma.user_group_order.findUnique({ 21 | where: { user_id: userId }, 22 | }); 23 | 24 | return { 25 | groupOrder: groupOrder?.group_order || [], 26 | }; 27 | } 28 | 29 | if (method === 'PUT') { 30 | const body = await readBody(event); 31 | const validatedGroupOrder = groupOrderSchema.parse(body); 32 | 33 | const groupOrder = await prisma.user_group_order.upsert({ 34 | where: { user_id: userId }, 35 | update: { 36 | group_order: validatedGroupOrder, 37 | updated_at: new Date(), 38 | }, 39 | create: { 40 | user_id: userId, 41 | group_order: validatedGroupOrder, 42 | }, 43 | }); 44 | 45 | return { 46 | groupOrder: groupOrder.group_order, 47 | }; 48 | } 49 | 50 | throw createError({ 51 | statusCode: 405, 52 | message: 'Method not allowed', 53 | }); 54 | }); -------------------------------------------------------------------------------- /server/tasks/jobs/clear-metrics/weekly.ts: -------------------------------------------------------------------------------- 1 | import { defineTask } from '#imports'; 2 | import { scopedLogger } from '../../../utils/logger'; 3 | import { setupMetrics } from '../../../utils/metrics'; 4 | 5 | const logger = scopedLogger('tasks:clear-metrics:weekly'); 6 | 7 | export default defineTask({ 8 | meta: { 9 | name: "jobs:clear-metrics:weekly", 10 | description: "Clear weekly metrics every Sunday at midnight", 11 | }, 12 | async run() { 13 | logger.info("Clearing weekly metrics"); 14 | const startTime = Date.now(); 15 | 16 | try { 17 | // Clear and reinitialize weekly metrics 18 | await setupMetrics('weekly', true); 19 | 20 | const executionTime = Date.now() - startTime; 21 | logger.info(`Weekly metrics cleared in ${executionTime}ms`); 22 | 23 | return { 24 | result: { 25 | status: "success", 26 | message: "Successfully cleared weekly metrics", 27 | executionTimeMs: executionTime, 28 | timestamp: new Date().toISOString() 29 | } 30 | }; 31 | } catch (error) { 32 | logger.error("Error clearing weekly metrics:", { error: error.message }); 33 | return { 34 | result: { 35 | status: "error", 36 | message: error.message || "An error occurred clearing weekly metrics", 37 | executionTimeMs: Date.now() - startTime, 38 | timestamp: new Date().toISOString() 39 | } 40 | }; 41 | } 42 | }, 43 | }); -------------------------------------------------------------------------------- /server/tasks/jobs/clear-metrics/monthly.ts: -------------------------------------------------------------------------------- 1 | import { defineTask } from '#imports'; 2 | import { scopedLogger } from '../../../utils/logger'; 3 | import { setupMetrics } from '../../../utils/metrics'; 4 | 5 | const logger = scopedLogger('tasks:clear-metrics:monthly'); 6 | 7 | export default defineTask({ 8 | meta: { 9 | name: "jobs:clear-metrics:monthly", 10 | description: "Clear monthly metrics on the 1st of each month at midnight", 11 | }, 12 | async run() { 13 | logger.info("Clearing monthly metrics"); 14 | const startTime = Date.now(); 15 | 16 | try { 17 | // Clear and reinitialize monthly metrics 18 | await setupMetrics('monthly', true); 19 | 20 | const executionTime = Date.now() - startTime; 21 | logger.info(`Monthly metrics cleared in ${executionTime}ms`); 22 | 23 | return { 24 | result: { 25 | status: "success", 26 | message: "Successfully cleared monthly metrics", 27 | executionTimeMs: executionTime, 28 | timestamp: new Date().toISOString() 29 | } 30 | }; 31 | } catch (error) { 32 | logger.error("Error clearing monthly metrics:", { error: error.message }); 33 | return { 34 | result: { 35 | status: "error", 36 | message: error.message || "An error occurred clearing monthly metrics", 37 | executionTimeMs: Date.now() - startTime, 38 | timestamp: new Date().toISOString() 39 | } 40 | }; 41 | } 42 | }, 43 | }); -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:15-alpine 4 | restart: unless-stopped 5 | healthcheck: 6 | test: ['CMD-SHELL', 'pg_isready -U $$PG_USER -d $$PG_DB'] 7 | interval: 5s 8 | retries: 10 9 | timeout: 2s 10 | environment: 11 | - POSTGRES_USER=${PG_USER} 12 | - POSTGRES_PASSWORD=${PG_PASSWORD} 13 | - POSTGRES_DB=${PG_DB} 14 | volumes: 15 | - postgres-data:/var/lib/postgresql/data 16 | env_file: 17 | - .env 18 | ports: 19 | - '5432:5432' 20 | networks: 21 | - p-stream-network 22 | 23 | p-stream: 24 | build: . 25 | restart: unless-stopped 26 | environment: 27 | DATABASE_URL: ${DATABASE_URL_DOCKER:?database URL required} 28 | META_NAME: ${META_NAME} 29 | META_DESCRIPTION: ${META_DESCRIPTION} 30 | CRYPTO_SECRET: ${CRYPTO_SECRET:?crypto secret required} 31 | TMDB_API_KEY: ${TMDB_API_KEY:?TMDB API key required} 32 | TRAKT_CLIENT_ID: ${TRAKT_CLIENT_ID:?Trakt client ID required} 33 | TRAKT_SECRET_ID: ${TRAKT_SECRET_ID:?Trakt secret ID required} 34 | CAPTCHA: ${CAPTCHA} 35 | CAPTCHA_CLIENT_KEY: ${CAPTCHA_CLIENT_KEY} 36 | NODE_ENV: ${NODE_ENV:-production} 37 | env_file: 38 | - .env 39 | ports: 40 | - '3000:3000' 41 | depends_on: 42 | postgres: 43 | condition: service_healthy 44 | networks: 45 | - p-stream-network 46 | 47 | networks: 48 | p-stream-network: 49 | driver: bridge 50 | 51 | volumes: 52 | postgres-data: 53 | -------------------------------------------------------------------------------- /server/utils/playerStatus.ts: -------------------------------------------------------------------------------- 1 | // Interface for player status 2 | export interface PlayerStatus { 3 | userId: string; 4 | roomCode: string; 5 | isHost: boolean; 6 | content: { 7 | title: string; 8 | type: string; 9 | tmdbId?: number | string; 10 | seasonId?: number; 11 | episodeId?: number; 12 | seasonNumber?: number; 13 | episodeNumber?: number; 14 | }; 15 | player: { 16 | isPlaying: boolean; 17 | isPaused: boolean; 18 | isLoading: boolean; 19 | hasPlayedOnce: boolean; 20 | time: number; 21 | duration: number; 22 | volume: number; 23 | playbackRate: number; 24 | buffered: number; 25 | }; 26 | timestamp: number; 27 | } 28 | 29 | // In-memory store for player status data 30 | // Key: userId+roomCode, Value: Status data array 31 | export const playerStatusStore = new Map>(); 32 | 33 | // Cleanup interval (30 minutes in milliseconds) 34 | export const CLEANUP_INTERVAL = 30 * 60 * 1000; 35 | 36 | // Clean up old status entries 37 | function cleanupOldStatuses() { 38 | const cutoffTime = Date.now() - CLEANUP_INTERVAL; 39 | 40 | for (const [key, statuses] of playerStatusStore.entries()) { 41 | const filteredStatuses = statuses.filter(status => status.timestamp >= cutoffTime); 42 | 43 | if (filteredStatuses.length === 0) { 44 | playerStatusStore.delete(key); 45 | } else { 46 | playerStatusStore.set(key, filteredStatuses); 47 | } 48 | } 49 | } 50 | 51 | // Schedule cleanup every 5 minutes 52 | setInterval(cleanupOldStatuses, 5 * 60 * 1000); 53 | -------------------------------------------------------------------------------- /server/routes/auth/derive-public-key.post.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { pbkdf2 } from 'crypto'; 3 | import nacl from 'tweetnacl'; 4 | 5 | const requestSchema = z.object({ 6 | mnemonic: z.string().min(1), 7 | }); 8 | 9 | function toBase64Url(input: Uint8Array): string { 10 | const base64 = Buffer.from(input).toString('base64'); 11 | return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); 12 | } 13 | 14 | function pbkdf2Async(password: string, salt: string, iterations: number, keyLen: number, digest: string): Promise { 15 | return new Promise((resolve, reject) => { 16 | pbkdf2(password, salt, iterations, keyLen, digest, (err, derivedKey) => { 17 | if (err) return reject(err); 18 | resolve(new Uint8Array(derivedKey)); 19 | }); 20 | }); 21 | } 22 | 23 | export default defineEventHandler(async (event) => { 24 | const body = await readBody(event); 25 | 26 | const parsed = requestSchema.safeParse(body); 27 | if (!parsed.success) { 28 | throw createError({ 29 | statusCode: 400, 30 | message: 'Invalid request body', 31 | }); 32 | } 33 | 34 | const { mnemonic } = parsed.data; 35 | 36 | // PBKDF2 (HMAC-SHA256) -> 32-byte seed, iterations = 2048, salt = "mnemonic" 37 | const seed = await pbkdf2Async(mnemonic, 'mnemonic', 2048, 32, 'sha256'); 38 | 39 | // Deterministic Ed25519 keypair from seed 40 | const keyPair = nacl.sign.keyPair.fromSeed(seed); 41 | const publicKeyBase64Url = toBase64Url(keyPair.publicKey); 42 | 43 | return { publicKey: publicKeyBase64Url }; 44 | }); 45 | 46 | 47 | // curl -X POST http://localhost:3000/auth/derive-public-key \ 48 | // -H 'Content-Type: application/json' \ 49 | // -d '{"mnemonic":"right inject hazard canoe carry unfair cram physical chief nice real tribute"}' -------------------------------------------------------------------------------- /server/middleware/metrics.ts: -------------------------------------------------------------------------------- 1 | import { recordHttpRequest } from '~/utils/metrics'; 2 | import { scopedLogger } from '~/utils/logger'; 3 | 4 | const log = scopedLogger('metrics-middleware'); 5 | 6 | // Paths we don't want to track metrics for 7 | const EXCLUDED_PATHS = ['/metrics', '/ping.txt', '/favicon.ico', '/robots.txt', '/sitemap.xml']; 8 | 9 | export default defineEventHandler(async event => { 10 | // Skip tracking excluded paths 11 | if (EXCLUDED_PATHS.includes(event.path)) { 12 | return; 13 | } 14 | 15 | const start = process.hrtime(); 16 | 17 | try { 18 | // Wait for the request to complete 19 | await event._handled; 20 | } finally { 21 | // Calculate duration once the response is sent 22 | const [seconds, nanoseconds] = process.hrtime(start); 23 | const duration = seconds + nanoseconds / 1e9; 24 | 25 | // Get cleaned route path (remove dynamic segments) 26 | const method = event.method; 27 | const route = getCleanPath(event.path); 28 | const statusCode = event.node.res.statusCode || 200; 29 | 30 | // Record the request metrics 31 | recordHttpRequest(method, route, statusCode, duration); 32 | 33 | log.debug('Recorded HTTP request metrics', { 34 | evt: 'http_metrics', 35 | method, 36 | route, 37 | statusCode, 38 | duration, 39 | }); 40 | } 41 | }); 42 | 43 | // Helper to normalize routes with dynamic segments (e.g., /users/123 -> /users/:id) 44 | function getCleanPath(path: string): string { 45 | // Common patterns for Nitro routes 46 | return path 47 | .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '/:uuid') 48 | .replace(/\/\d+/g, '/:id') 49 | .replace(/@me/, ':uid') 50 | .replace(/\/[^\/]+\/progress\/[^\/]+/, '/:uid/progress/:tmdbid') 51 | .replace(/\/[^\/]+\/bookmarks\/[^\/]+/, '/:uid/bookmarks/:tmdbid') 52 | .replace(/\/sessions\/[^\/]+/, '/sessions/:sid'); 53 | } 54 | -------------------------------------------------------------------------------- /server/api/player/status.post.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, readBody, createError } from 'h3'; 2 | import { playerStatusStore, PlayerStatus } from '~/utils/playerStatus'; 3 | 4 | export default defineEventHandler(async event => { 5 | const body = await readBody(event); 6 | 7 | if (!body || !body.userId || !body.roomCode) { 8 | throw createError({ 9 | statusCode: 400, 10 | statusMessage: 'Missing required fields: userId, roomCode', 11 | }); 12 | } 13 | 14 | const status: PlayerStatus = { 15 | userId: body.userId, 16 | roomCode: body.roomCode, 17 | isHost: body.isHost || false, 18 | content: { 19 | title: body.content?.title || 'Unknown', 20 | type: body.content?.type || 'Unknown', 21 | tmdbId: body.content?.tmdbId, 22 | seasonId: body.content?.seasonId, 23 | episodeId: body.content?.episodeId, 24 | seasonNumber: body.content?.seasonNumber, 25 | episodeNumber: body.content?.episodeNumber, 26 | }, 27 | player: { 28 | isPlaying: body.player?.isPlaying || false, 29 | isPaused: body.player?.isPaused || false, 30 | isLoading: body.player?.isLoading || false, 31 | hasPlayedOnce: body.player?.hasPlayedOnce || false, 32 | time: body.player?.time || 0, 33 | duration: body.player?.duration || 0, 34 | volume: body.player?.volume || 0, 35 | playbackRate: body.player?.playbackRate || 1, 36 | buffered: body.player?.buffered || 0, 37 | }, 38 | timestamp: Date.now(), 39 | }; 40 | 41 | const key = `${status.userId}:${status.roomCode}`; 42 | const existingStatuses = playerStatusStore.get(key) || []; 43 | 44 | // Add new status and keep only the last 5 statuses 45 | existingStatuses.push(status); 46 | if (existingStatuses.length > 5) { 47 | existingStatuses.shift(); 48 | } 49 | 50 | playerStatusStore.set(key, existingStatuses); 51 | 52 | return { success: true, timestamp: status.timestamp }; 53 | }); 54 | -------------------------------------------------------------------------------- /server/api/player/status.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, getQuery, createError } from 'h3'; 2 | import { playerStatusStore, CLEANUP_INTERVAL } from '~/utils/playerStatus'; 3 | 4 | export default defineEventHandler(event => { 5 | const query = getQuery(event); 6 | const userId = query.userId as string; 7 | const roomCode = query.roomCode as string; 8 | 9 | // If roomCode is provided but no userId, return all statuses for that room 10 | if (roomCode && !userId) { 11 | const cutoffTime = Date.now() - CLEANUP_INTERVAL; 12 | const roomStatuses: Record = {}; 13 | 14 | for (const [key, statuses] of playerStatusStore.entries()) { 15 | if (key.includes(`:${roomCode}`)) { 16 | const userId = key.split(':')[0]; 17 | const recentStatuses = statuses.filter(status => status.timestamp >= cutoffTime); 18 | 19 | if (recentStatuses.length > 0) { 20 | roomStatuses[userId] = recentStatuses; 21 | } 22 | } 23 | } 24 | 25 | return { 26 | roomCode, 27 | users: roomStatuses, 28 | }; 29 | } 30 | 31 | // If both userId and roomCode are provided, return status for that user in that room 32 | if (userId && roomCode) { 33 | const key = `${userId}:${roomCode}`; 34 | const statuses = playerStatusStore.get(key) || []; 35 | 36 | // Remove statuses older than the cleanup interval (30 minutes) 37 | const cutoffTime = Date.now() - CLEANUP_INTERVAL; 38 | const recentStatuses = statuses.filter(status => status.timestamp >= cutoffTime); 39 | 40 | if (recentStatuses.length !== statuses.length) { 41 | playerStatusStore.set(key, recentStatuses); 42 | } 43 | 44 | return { 45 | userId, 46 | roomCode, 47 | statuses: recentStatuses, 48 | }; 49 | } 50 | 51 | // If neither is provided, return error 52 | throw createError({ 53 | statusCode: 400, 54 | statusMessage: 'Missing required query parameters: roomCode and/or userId', 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /server/routes/auth/login/complete/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { useChallenge } from '~/utils/challenge'; 3 | import { useAuth } from '~/utils/auth'; 4 | 5 | const completeSchema = z.object({ 6 | publicKey: z.string(), 7 | challenge: z.object({ 8 | code: z.string(), 9 | signature: z.string(), 10 | }), 11 | device: z.string().max(500).min(1), 12 | }); 13 | 14 | export default defineEventHandler(async event => { 15 | const body = await readBody(event); 16 | 17 | const result = completeSchema.safeParse(body); 18 | if (!result.success) { 19 | throw createError({ 20 | statusCode: 400, 21 | message: 'Invalid request body', 22 | }); 23 | } 24 | 25 | const challenge = useChallenge(); 26 | await challenge.verifyChallengeCode( 27 | body.challenge.code, 28 | body.publicKey, 29 | body.challenge.signature, 30 | 'login', 31 | 'mnemonic' 32 | ); 33 | 34 | const user = await prisma.users.findUnique({ 35 | where: { public_key: body.publicKey }, 36 | }); 37 | 38 | if (!user) { 39 | throw createError({ 40 | statusCode: 401, 41 | message: 'User cannot be found', 42 | }); 43 | } 44 | 45 | await prisma.users.update({ 46 | where: { id: user.id }, 47 | data: { last_logged_in: new Date() }, 48 | }); 49 | 50 | const auth = useAuth(); 51 | const userAgent = getRequestHeader(event, 'user-agent') || ''; 52 | const session = await auth.makeSession(user.id, body.device, userAgent); 53 | const token = auth.makeSessionToken(session); 54 | 55 | return { 56 | user: { 57 | id: user.id, 58 | publicKey: user.public_key, 59 | namespace: user.namespace, 60 | profile: user.profile, 61 | permissions: user.permissions, 62 | }, 63 | session: { 64 | id: session.id, 65 | user: session.user, 66 | createdAt: session.created_at, 67 | accessedAt: session.accessed_at, 68 | expiresAt: session.expires_at, 69 | device: session.device, 70 | userAgent: session.user_agent, 71 | }, 72 | token, 73 | }; 74 | }); 75 | -------------------------------------------------------------------------------- /server/routes/users/[id]/lists/index.post.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '#imports'; 2 | import { prisma } from '~/utils/prisma'; 3 | import { z } from 'zod'; 4 | 5 | const listItemSchema = z.object({ 6 | tmdb_id: z.string(), 7 | type: z.enum(['movie', 'tv']), 8 | }); 9 | 10 | const createListSchema = z.object({ 11 | name: z.string().min(1).max(255), 12 | description: z.string().max(255).optional().nullable(), 13 | items: z.array(listItemSchema).optional(), 14 | public: z.boolean().optional(), 15 | }); 16 | 17 | export default defineEventHandler(async event => { 18 | const userId = event.context.params?.id; 19 | const session = await useAuth().getCurrentSession(); 20 | 21 | if (session.user !== userId) { 22 | throw createError({ 23 | statusCode: 403, 24 | message: 'Cannot modify user other than yourself', 25 | }); 26 | } 27 | 28 | const body = await readBody(event); 29 | 30 | let parsedBody; 31 | try { 32 | parsedBody = typeof body === 'string' ? JSON.parse(body) : body; 33 | } catch (error) { 34 | throw createError({ 35 | statusCode: 400, 36 | message: 'Invalid request body format', 37 | }); 38 | } 39 | 40 | const validatedBody = createListSchema.parse(parsedBody); 41 | 42 | const result = await prisma.$transaction(async tx => { 43 | const newList = await tx.lists.create({ 44 | data: { 45 | user_id: userId, 46 | name: validatedBody.name, 47 | description: validatedBody.description || null, 48 | public: validatedBody.public || false, 49 | }, 50 | }); 51 | 52 | if (validatedBody.items && validatedBody.items.length > 0) { 53 | await tx.list_items.createMany({ 54 | data: validatedBody.items.map(item => ({ 55 | list_id: newList.id, 56 | tmdb_id: item.tmdb_id, 57 | type: item.type, // Type is mapped here 58 | })), 59 | skipDuplicates: true, 60 | }); 61 | } 62 | 63 | return tx.lists.findUnique({ 64 | where: { id: newList.id }, 65 | include: { list_items: true }, 66 | }); 67 | }); 68 | 69 | return { 70 | list: result, 71 | message: 'List created successfully', 72 | }; 73 | }); 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report or Feature Request 2 | description: File a bug report or request a new feature... llm template so sorry if it breaks 3 | title: '[BUG/FEATURE]: ' 4 | labels: ['triage'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this issue! 10 | - type: dropdown 11 | id: issue-type 12 | attributes: 13 | label: Issue Type 14 | description: What type of issue are you reporting? 15 | options: 16 | - Bug 17 | - Feature Request 18 | - Documentation Issue 19 | - Question 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: description 24 | attributes: 25 | label: Description 26 | description: A clear and concise description of the issue. 27 | placeholder: Tell us what you see or want to see! 28 | validations: 29 | required: true 30 | - type: textarea 31 | id: expected 32 | attributes: 33 | label: Expected Behavior 34 | description: What did you expect to happen? 35 | placeholder: Describe what you expected to happen. 36 | validations: 37 | required: false 38 | - type: textarea 39 | id: current 40 | attributes: 41 | label: Current Behavior 42 | description: What actually happened? 43 | placeholder: Describe what actually happened. 44 | validations: 45 | required: false 46 | - type: textarea 47 | id: steps 48 | attributes: 49 | label: Steps To Reproduce 50 | description: Steps to reproduce the behavior. 51 | placeholder: | 52 | 1. In this environment... 53 | 2. With this config... 54 | 3. Run '...' 55 | 4. See error... 56 | validations: 57 | required: false 58 | - type: textarea 59 | id: environment 60 | attributes: 61 | label: Environment 62 | description: | 63 | examples: 64 | - **OS**: Ubuntu 20.04 65 | - **Node**: 14.17.0 66 | - **npm**: 6.14.13 67 | value: | 68 | - OS: 69 | - Node: 70 | - npm: 71 | render: markdown 72 | validations: 73 | required: false 74 | -------------------------------------------------------------------------------- /server/routes/users/[id]/ratings.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | 4 | const userRatingsSchema = z.object({ 5 | tmdb_id: z.number(), 6 | type: z.enum(['movie', 'tv']), 7 | rating: z.number().min(0).max(10), 8 | }); 9 | 10 | export default defineEventHandler(async event => { 11 | const userId = event.context.params?.id; 12 | 13 | const session = await useAuth().getCurrentSession(); 14 | 15 | if (session.user !== userId) { 16 | throw createError({ 17 | statusCode: 403, 18 | message: 'Permission denied', 19 | }); 20 | } 21 | 22 | if (event.method === 'GET') { 23 | const ratings = await prisma.users.findMany({ 24 | select: { 25 | ratings: true, 26 | }, 27 | where: { 28 | id: userId, 29 | }, 30 | }); 31 | 32 | return { 33 | userId, 34 | ratings: ratings[0].ratings, 35 | }; 36 | } else if (event.method === 'POST') { 37 | const body = await readBody(event); 38 | const validatedBody = userRatingsSchema.parse(body); 39 | 40 | const user = await prisma.users.findUnique({ 41 | where: { 42 | id: userId, 43 | }, 44 | select: { 45 | ratings: true, 46 | }, 47 | }); 48 | 49 | const userRatings = user?.ratings || []; 50 | const currentRatings = Array.isArray(userRatings) ? userRatings : []; 51 | 52 | const existingRatingIndex = currentRatings.findIndex( 53 | (r: any) => r.tmdb_id === validatedBody.tmdb_id && r.type === validatedBody.type 54 | ); 55 | 56 | let updatedRatings; 57 | if (existingRatingIndex >= 0) { 58 | updatedRatings = [...currentRatings]; 59 | updatedRatings[existingRatingIndex] = validatedBody; 60 | } else { 61 | updatedRatings = [...currentRatings, validatedBody]; 62 | } 63 | 64 | await prisma.users.update({ 65 | where: { 66 | id: userId, 67 | }, 68 | data: { 69 | ratings: updatedRatings, 70 | }, 71 | }); 72 | 73 | return { 74 | userId, 75 | rating: { 76 | tmdb_id: validatedBody.tmdb_id, 77 | type: validatedBody.type, 78 | rating: validatedBody.rating, 79 | }, 80 | }; 81 | } 82 | 83 | // This should only execute if the method is neither GET nor POST 84 | throw createError({ 85 | statusCode: 405, 86 | message: 'Method not allowed', 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /server/routes/metrics/providers.post.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { getMetrics, recordProviderMetrics } from '~/utils/metrics'; 3 | import { scopedLogger } from '~/utils/logger'; 4 | import { setupMetrics } from '~/utils/metrics'; 5 | 6 | const log = scopedLogger('metrics-providers'); 7 | 8 | let isInitialized = false; 9 | 10 | async function ensureMetricsInitialized() { 11 | if (!isInitialized) { 12 | log.info('Initializing metrics from providers endpoint...', { evt: 'init_start' }); 13 | await setupMetrics(); 14 | isInitialized = true; 15 | log.info('Metrics initialized from providers endpoint', { evt: 'init_complete' }); 16 | } 17 | } 18 | 19 | const metricsProviderSchema = z.object({ 20 | tmdbId: z.string(), 21 | type: z.string(), 22 | title: z.string(), 23 | seasonId: z.string().optional(), 24 | episodeId: z.string().optional(), 25 | status: z.string(), 26 | providerId: z.string(), 27 | embedId: z.string().optional(), 28 | errorMessage: z.string().optional(), 29 | fullError: z.string().optional(), 30 | }); 31 | 32 | const metricsProviderInputSchema = z.object({ 33 | items: z.array(metricsProviderSchema).max(10).min(1), 34 | tool: z.string().optional(), 35 | batchId: z.string().optional(), 36 | }); 37 | 38 | export default defineEventHandler(async event => { 39 | // Handle both POST and PUT methods 40 | if (event.method !== 'POST' && event.method !== 'PUT') { 41 | throw createError({ 42 | statusCode: 405, 43 | message: 'Method not allowed', 44 | }); 45 | } 46 | 47 | try { 48 | await ensureMetricsInitialized(); 49 | 50 | const body = await readBody(event); 51 | const validatedBody = metricsProviderInputSchema.parse(body); 52 | 53 | const hostname = event.node.req.headers.origin?.slice(0, 255) ?? ''; 54 | 55 | // Use the simplified recordProviderMetrics function to handle all metrics recording 56 | recordProviderMetrics(validatedBody.items, hostname, validatedBody.tool); 57 | 58 | return true; 59 | } catch (error) { 60 | log.error('Failed to process metrics', { 61 | evt: 'metrics_error', 62 | error: error instanceof Error ? error.message : String(error), 63 | }); 64 | throw createError({ 65 | statusCode: error instanceof Error && error.message === 'metrics not initialized' ? 503 : 400, 66 | message: error instanceof Error ? error.message : 'Failed to process metrics', 67 | }); 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /server/routes/sessions/[sid]/index.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | 4 | const updateSessionSchema = z.object({ 5 | deviceName: z.string().max(500).min(1).optional(), 6 | }); 7 | 8 | export default defineEventHandler(async event => { 9 | const sessionId = getRouterParam(event, 'sid'); 10 | 11 | const currentSession = await useAuth().getCurrentSession(); 12 | 13 | const targetedSession = await prisma.sessions.findUnique({ 14 | where: { id: sessionId }, 15 | }); 16 | 17 | if (!targetedSession) { 18 | if (event.method === 'DELETE') { 19 | return { id: sessionId }; 20 | } 21 | 22 | throw createError({ 23 | statusCode: 404, 24 | message: 'Session cannot be found', 25 | }); 26 | } 27 | 28 | if (targetedSession.user !== currentSession.user) { 29 | throw createError({ 30 | statusCode: 401, 31 | message: 32 | event.method === 'DELETE' 33 | ? 'Cannot delete sessions you do not own' 34 | : 'Cannot edit sessions other than your own', 35 | }); 36 | } 37 | 38 | if (event.method === 'PATCH') { 39 | const body = await readBody(event); 40 | const validatedBody = updateSessionSchema.parse(body); 41 | 42 | if (validatedBody.deviceName) { 43 | await prisma.sessions.update({ 44 | where: { id: sessionId }, 45 | data: { 46 | device: validatedBody.deviceName, 47 | }, 48 | }); 49 | } 50 | 51 | const updatedSession = await prisma.sessions.findUnique({ 52 | where: { id: sessionId }, 53 | }); 54 | 55 | return { 56 | id: updatedSession.id, 57 | user: updatedSession.user, 58 | createdAt: updatedSession.created_at, 59 | accessedAt: updatedSession.accessed_at, 60 | expiresAt: updatedSession.expires_at, 61 | device: updatedSession.device, 62 | userAgent: updatedSession.user_agent, 63 | current: updatedSession.id === currentSession.id, 64 | }; 65 | } 66 | 67 | if (event.method === 'DELETE') { 68 | const sid = event.context.params?.sid; 69 | const sessionExists = await prisma.sessions.findUnique({ 70 | where: { id: sid }, 71 | }); 72 | 73 | if (!sessionExists) { 74 | return { success: true }; 75 | } 76 | const session = await useAuth().getSessionAndBump(sid); 77 | 78 | await prisma.sessions.delete({ 79 | where: { id: sessionId }, 80 | }); 81 | 82 | return { id: sessionId }; 83 | } 84 | 85 | throw createError({ 86 | statusCode: 405, 87 | message: 'Method not allowed', 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /server/routes/auth/register/complete.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { useChallenge } from '~/utils/challenge'; 3 | import { useAuth } from '~/utils/auth'; 4 | import { randomUUID } from 'crypto'; 5 | import { generateRandomNickname } from '~/utils/nickname'; 6 | 7 | const completeSchema = z.object({ 8 | publicKey: z.string(), 9 | challenge: z.object({ 10 | code: z.string(), 11 | signature: z.string(), 12 | }), 13 | namespace: z.string().min(1), 14 | device: z.string().max(500).min(1), 15 | profile: z.object({ 16 | colorA: z.string(), 17 | colorB: z.string(), 18 | icon: z.string(), 19 | }), 20 | }); 21 | 22 | export default defineEventHandler(async event => { 23 | const body = await readBody(event); 24 | 25 | const result = completeSchema.safeParse(body); 26 | if (!result.success) { 27 | throw createError({ 28 | statusCode: 400, 29 | message: 'Invalid request body', 30 | }); 31 | } 32 | 33 | const challenge = useChallenge(); 34 | await challenge.verifyChallengeCode( 35 | body.challenge.code, 36 | body.publicKey, 37 | body.challenge.signature, 38 | 'registration', 39 | 'mnemonic' 40 | ); 41 | 42 | const existingUser = await prisma.users.findUnique({ 43 | where: { public_key: body.publicKey }, 44 | }); 45 | 46 | if (existingUser) { 47 | throw createError({ 48 | statusCode: 409, 49 | message: 'A user with this public key already exists', 50 | }); 51 | } 52 | 53 | const userId = randomUUID(); 54 | const now = new Date(); 55 | const nickname = generateRandomNickname(); 56 | 57 | const user = await prisma.users.create({ 58 | data: { 59 | id: userId, 60 | namespace: body.namespace, 61 | public_key: body.publicKey, 62 | nickname, 63 | created_at: now, 64 | last_logged_in: now, 65 | permissions: [], 66 | profile: body.profile, 67 | } as any, 68 | }); 69 | 70 | const auth = useAuth(); 71 | const userAgent = getRequestHeader(event, 'user-agent'); 72 | const session = await auth.makeSession(user.id, body.device, userAgent); 73 | const token = auth.makeSessionToken(session); 74 | 75 | return { 76 | user: { 77 | id: user.id, 78 | publicKey: user.public_key, 79 | namespace: user.namespace, 80 | nickname: (user as any).nickname, 81 | profile: user.profile, 82 | permissions: user.permissions, 83 | }, 84 | session: { 85 | id: session.id, 86 | user: session.user, 87 | createdAt: session.created_at, 88 | accessedAt: session.accessed_at, 89 | expiresAt: session.expires_at, 90 | device: session.device, 91 | userAgent: session.user_agent, 92 | }, 93 | token, 94 | }; 95 | }); 96 | -------------------------------------------------------------------------------- /server/utils/challenge.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto'; 2 | import { prisma } from './prisma'; 3 | import nacl from 'tweetnacl'; 4 | 5 | // Challenge code expires in 10 minutes 6 | const CHALLENGE_EXPIRY_MS = 10 * 60 * 1000; 7 | 8 | export function useChallenge() { 9 | const createChallengeCode = async (flow: string, authType: string) => { 10 | const now = new Date(); 11 | const expiryDate = new Date(now.getTime() + CHALLENGE_EXPIRY_MS); 12 | 13 | return await prisma.challenge_codes.create({ 14 | data: { 15 | code: randomUUID(), 16 | flow, 17 | auth_type: authType, 18 | created_at: now, 19 | expires_at: expiryDate, 20 | }, 21 | }); 22 | }; 23 | 24 | const verifyChallengeCode = async ( 25 | code: string, 26 | publicKey: string, 27 | signature: string, 28 | flow: string, 29 | authType: string 30 | ) => { 31 | const challengeCode = await prisma.challenge_codes.findUnique({ 32 | where: { code }, 33 | }); 34 | 35 | if (!challengeCode) { 36 | throw new Error('Invalid challenge code'); 37 | } 38 | 39 | if (challengeCode.flow !== flow || challengeCode.auth_type !== authType) { 40 | throw new Error('Invalid challenge flow or auth type'); 41 | } 42 | 43 | if (new Date(challengeCode.expires_at) < new Date()) { 44 | throw new Error('Challenge code expired'); 45 | } 46 | 47 | const isValidSignature = verifySignature(code, publicKey, signature); 48 | if (!isValidSignature) { 49 | throw new Error('Invalid signature'); 50 | } 51 | 52 | await prisma.challenge_codes.delete({ 53 | where: { code }, 54 | }); 55 | 56 | return true; 57 | }; 58 | 59 | const verifySignature = (data: string, publicKey: string, signature: string) => { 60 | try { 61 | let normalizedSignature = signature.replace(/-/g, '+').replace(/_/g, '/'); 62 | while (normalizedSignature.length % 4 !== 0) { 63 | normalizedSignature += '='; 64 | } 65 | 66 | let normalizedPublicKey = publicKey.replace(/-/g, '+').replace(/_/g, '/'); 67 | while (normalizedPublicKey.length % 4 !== 0) { 68 | normalizedPublicKey += '='; 69 | } 70 | 71 | const signatureBuffer = Buffer.from(normalizedSignature, 'base64'); 72 | const publicKeyBuffer = Buffer.from(normalizedPublicKey, 'base64'); 73 | const messageBuffer = Buffer.from(data); 74 | 75 | return nacl.sign.detached.verify(messageBuffer, signatureBuffer, publicKeyBuffer); 76 | } catch (error) { 77 | console.error('Signature verification error:', error); 78 | return false; 79 | } 80 | }; 81 | 82 | return { 83 | createChallengeCode, 84 | verifyChallengeCode, 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /prisma/migrations/20250310013319_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "bookmarks" ( 3 | "tmdb_id" VARCHAR(255) NOT NULL, 4 | "user_id" VARCHAR(255) NOT NULL, 5 | "meta" JSONB NOT NULL, 6 | "updated_at" TIMESTAMPTZ(0) NOT NULL, 7 | 8 | CONSTRAINT "bookmarks_pkey" PRIMARY KEY ("tmdb_id","user_id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "challenge_codes" ( 13 | "code" UUID NOT NULL, 14 | "flow" TEXT NOT NULL, 15 | "auth_type" VARCHAR(255) NOT NULL, 16 | "created_at" TIMESTAMPTZ(0) NOT NULL, 17 | "expires_at" TIMESTAMPTZ(0) NOT NULL, 18 | 19 | CONSTRAINT "challenge_codes_pkey" PRIMARY KEY ("code") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "mikro_orm_migrations" ( 24 | "id" SERIAL NOT NULL, 25 | "name" VARCHAR(255), 26 | "executed_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, 27 | 28 | CONSTRAINT "mikro_orm_migrations_pkey" PRIMARY KEY ("id") 29 | ); 30 | 31 | -- CreateTable 32 | CREATE TABLE "progress_items" ( 33 | "id" UUID NOT NULL, 34 | "tmdb_id" VARCHAR(255) NOT NULL, 35 | "user_id" VARCHAR(255) NOT NULL, 36 | "season_id" VARCHAR(255), 37 | "episode_id" VARCHAR(255), 38 | "meta" JSONB NOT NULL, 39 | "updated_at" TIMESTAMPTZ(0) NOT NULL, 40 | "duration" BIGINT NOT NULL, 41 | "watched" BIGINT NOT NULL, 42 | "season_number" INTEGER, 43 | "episode_number" INTEGER, 44 | 45 | CONSTRAINT "progress_items_pkey" PRIMARY KEY ("id") 46 | ); 47 | 48 | -- CreateTable 49 | CREATE TABLE "sessions" ( 50 | "id" UUID NOT NULL, 51 | "user" TEXT NOT NULL, 52 | "created_at" TIMESTAMPTZ(0) NOT NULL, 53 | "accessed_at" TIMESTAMPTZ(0) NOT NULL, 54 | "expires_at" TIMESTAMPTZ(0) NOT NULL, 55 | "device" TEXT NOT NULL, 56 | "user_agent" TEXT NOT NULL, 57 | 58 | CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") 59 | ); 60 | 61 | -- CreateTable 62 | CREATE TABLE "user_settings" ( 63 | "id" TEXT NOT NULL, 64 | "application_theme" VARCHAR(255), 65 | "application_language" VARCHAR(255), 66 | "default_subtitle_language" VARCHAR(255), 67 | "proxy_urls" TEXT[], 68 | "trakt_key" VARCHAR(255), 69 | "febbox_key" VARCHAR(255), 70 | 71 | CONSTRAINT "user_settings_pkey" PRIMARY KEY ("id") 72 | ); 73 | 74 | -- CreateTable 75 | CREATE TABLE "users" ( 76 | "id" TEXT NOT NULL, 77 | "public_key" TEXT NOT NULL, 78 | "namespace" VARCHAR(255) NOT NULL, 79 | "created_at" TIMESTAMPTZ(0) NOT NULL, 80 | "last_logged_in" TIMESTAMPTZ(0), 81 | "permissions" TEXT[], 82 | "profile" JSONB NOT NULL, 83 | 84 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 85 | ); 86 | 87 | -- CreateIndex 88 | CREATE UNIQUE INDEX "bookmarks_tmdb_id_user_id_unique" ON "bookmarks"("tmdb_id", "user_id"); 89 | 90 | -- CreateIndex 91 | CREATE UNIQUE INDEX "progress_items_tmdb_id_user_id_season_id_episode_id_unique" ON "progress_items"("tmdb_id", "user_id", "season_id", "episode_id"); 92 | 93 | -- CreateIndex 94 | CREATE UNIQUE INDEX "users_public_key_unique" ON "users"("public_key"); 95 | -------------------------------------------------------------------------------- /server/routes/users/[id]/bookmarks/[tmdbid]/index.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { scopedLogger } from '~/utils/logger'; 4 | 5 | const log = scopedLogger('user-bookmarks'); 6 | 7 | const bookmarkMetaSchema = z.object({ 8 | title: z.string(), 9 | year: z.number(), 10 | poster: z.string().optional(), 11 | type: z.enum(['movie', 'show']), 12 | }); 13 | 14 | const bookmarkRequestSchema = z.object({ 15 | meta: bookmarkMetaSchema.optional(), 16 | tmdbId: z.string().optional(), 17 | group: z.union([z.string(), z.array(z.string())]).optional(), 18 | favoriteEpisodes: z.array(z.string()).optional(), 19 | }); 20 | 21 | export default defineEventHandler(async event => { 22 | const userId = getRouterParam(event, 'id'); 23 | const tmdbId = getRouterParam(event, 'tmdbid'); 24 | const session = await useAuth().getCurrentSession(); 25 | 26 | if (session.user !== userId) { 27 | throw createError({ statusCode: 403, message: 'Cannot access bookmarks for other users' }); 28 | } 29 | 30 | if (event.method === 'POST') { 31 | try { 32 | const body = await readBody(event); 33 | log.info('Creating bookmark', { userId, tmdbId, body }); 34 | 35 | const validated = bookmarkRequestSchema.parse(body); 36 | const meta = bookmarkMetaSchema.parse(validated.meta || body); 37 | const group = validated.group ? (Array.isArray(validated.group) ? validated.group : [validated.group]) : []; 38 | const favoriteEpisodes = validated.favoriteEpisodes || []; 39 | 40 | const bookmark = await prisma.bookmarks.upsert({ 41 | where: { tmdb_id_user_id: { tmdb_id: tmdbId, user_id: session.user } }, 42 | update: { meta, group, favorite_episodes: favoriteEpisodes, updated_at: new Date() }, 43 | create: { user_id: session.user, tmdb_id: tmdbId, meta, group, favorite_episodes: favoriteEpisodes, updated_at: new Date() }, 44 | }); 45 | 46 | log.info('Bookmark created successfully', { userId, tmdbId }); 47 | return { 48 | tmdbId: bookmark.tmdb_id, 49 | meta: bookmark.meta, 50 | group: bookmark.group, 51 | favoriteEpisodes: bookmark.favorite_episodes, 52 | updatedAt: bookmark.updated_at, 53 | }; 54 | } catch (error) { 55 | log.error('Failed to create bookmark', { userId, tmdbId, error: error instanceof Error ? error.message : String(error) }); 56 | if (error instanceof z.ZodError) throw createError({ statusCode: 400, message: JSON.stringify(error.errors, null, 2) }); 57 | throw error; 58 | } 59 | } else if (event.method === 'DELETE') { 60 | log.info('Deleting bookmark', { userId, tmdbId }); 61 | try { 62 | await prisma.bookmarks.delete({ where: { tmdb_id_user_id: { tmdb_id: tmdbId, user_id: session.user } } }); 63 | log.info('Bookmark deleted successfully', { userId, tmdbId }); 64 | } catch (error) { 65 | log.error('Failed to delete bookmark', { userId, tmdbId, error: error instanceof Error ? error.message : String(error) }); 66 | } 67 | return { success: true, tmdbId }; 68 | } 69 | 70 | throw createError({ statusCode: 405, message: 'Method not allowed' }); 71 | }); 72 | -------------------------------------------------------------------------------- /server/routes/users/[id]/bookmarks.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { bookmarks } from '@prisma/client'; 4 | 5 | const bookmarkMetaSchema = z.object({ 6 | title: z.string(), 7 | year: z.number().optional(), 8 | poster: z.string().optional(), 9 | type: z.enum(['movie', 'show']), 10 | }); 11 | 12 | const bookmarkDataSchema = z.object({ 13 | tmdbId: z.string(), 14 | meta: bookmarkMetaSchema, 15 | group: z.union([z.string(), z.array(z.string())]).optional(), 16 | favoriteEpisodes: z.array(z.string()).optional(), 17 | }); 18 | 19 | export default defineEventHandler(async event => { 20 | const userId = event.context.params?.id; 21 | const method = event.method; 22 | 23 | const session = await useAuth().getCurrentSession(); 24 | 25 | if (session.user !== userId) { 26 | throw createError({ 27 | statusCode: 403, 28 | message: 'Cannot access other user information', 29 | }); 30 | } 31 | 32 | if (method === 'GET') { 33 | const bookmarks = await prisma.bookmarks.findMany({ 34 | where: { user_id: userId }, 35 | }); 36 | 37 | return bookmarks.map((bookmark: bookmarks) => ({ 38 | tmdbId: bookmark.tmdb_id, 39 | meta: bookmark.meta, 40 | group: bookmark.group, 41 | favoriteEpisodes: bookmark.favorite_episodes, 42 | updatedAt: bookmark.updated_at, 43 | })); 44 | } 45 | 46 | if (method === 'PUT') { 47 | const body = await readBody(event); 48 | const validatedBody = z.array(bookmarkDataSchema).parse(body); 49 | 50 | const now = new Date(); 51 | const results = []; 52 | 53 | for (const item of validatedBody) { 54 | // Normalize group to always be an array 55 | const normalizedGroup = item.group 56 | ? (Array.isArray(item.group) ? item.group : [item.group]) 57 | : []; 58 | 59 | // Normalize favoriteEpisodes to always be an array 60 | const normalizedFavoriteEpisodes = item.favoriteEpisodes || []; 61 | 62 | const bookmark = await prisma.bookmarks.upsert({ 63 | where: { 64 | tmdb_id_user_id: { 65 | tmdb_id: item.tmdbId, 66 | user_id: userId, 67 | }, 68 | }, 69 | update: { 70 | meta: item.meta, 71 | group: normalizedGroup, 72 | favorite_episodes: normalizedFavoriteEpisodes, 73 | updated_at: now, 74 | } as any, 75 | create: { 76 | tmdb_id: item.tmdbId, 77 | user_id: userId, 78 | meta: item.meta, 79 | group: normalizedGroup, 80 | favorite_episodes: normalizedFavoriteEpisodes, 81 | updated_at: now, 82 | } as any, 83 | }) as bookmarks; 84 | 85 | results.push({ 86 | tmdbId: bookmark.tmdb_id, 87 | meta: bookmark.meta, 88 | group: bookmark.group, 89 | favoriteEpisodes: bookmark.favorite_episodes, 90 | updatedAt: bookmark.updated_at, 91 | }); 92 | } 93 | 94 | return results; 95 | } 96 | 97 | 98 | throw createError({ 99 | statusCode: 405, 100 | message: 'Method not allowed', 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /server/routes/users/[id]/lists/index.patch.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '#imports'; 2 | import { z } from 'zod'; 3 | import { prisma } from '#imports'; 4 | 5 | const listItemSchema = z.object({ 6 | tmdb_id: z.string(), 7 | type: z.enum(['movie', 'tv']), 8 | }); 9 | 10 | const updateListSchema = z.object({ 11 | list_id: z.string().uuid(), 12 | name: z.string().min(1).max(255).optional(), 13 | description: z.string().max(255).optional().nullable(), 14 | public: z.boolean().optional(), 15 | addItems: z.array(listItemSchema).optional(), 16 | removeItems: z.array(listItemSchema).optional(), 17 | }); 18 | 19 | export default defineEventHandler(async event => { 20 | const userId = event.context.params?.id; 21 | const session = await useAuth().getCurrentSession(); 22 | 23 | if (session.user !== userId) { 24 | throw createError({ 25 | statusCode: 403, 26 | message: 'Cannot modify lists for other users', 27 | }); 28 | } 29 | 30 | const body = await readBody(event); 31 | const validatedBody = updateListSchema.parse(body); 32 | 33 | const list = await prisma.lists.findUnique({ 34 | where: { id: validatedBody.list_id }, 35 | include: { list_items: true }, 36 | }); 37 | 38 | if (!list) { 39 | throw createError({ 40 | statusCode: 404, 41 | message: 'List not found', 42 | }); 43 | } 44 | 45 | if (list.user_id !== userId) { 46 | throw createError({ 47 | statusCode: 403, 48 | message: "Cannot modify lists you don't own", 49 | }); 50 | } 51 | 52 | const result = await prisma.$transaction(async tx => { 53 | if ( 54 | validatedBody.name || 55 | validatedBody.description !== undefined || 56 | validatedBody.public !== undefined 57 | ) { 58 | await tx.lists.update({ 59 | where: { id: list.id }, 60 | data: { 61 | name: validatedBody.name ?? list.name, 62 | description: 63 | validatedBody.description !== undefined ? validatedBody.description : list.description, 64 | public: validatedBody.public ?? list.public, 65 | }, 66 | }); 67 | } 68 | 69 | if (validatedBody.addItems && validatedBody.addItems.length > 0) { 70 | const existingTmdbIds = list.list_items.map(item => item.tmdb_id); 71 | 72 | const itemsToAdd = validatedBody.addItems.filter( 73 | item => !existingTmdbIds.includes(item.tmdb_id) 74 | ); 75 | 76 | if (itemsToAdd.length > 0) { 77 | await tx.list_items.createMany({ 78 | data: itemsToAdd.map(item => ({ 79 | list_id: list.id, 80 | tmdb_id: item.tmdb_id, 81 | type: item.type, 82 | })), 83 | skipDuplicates: true, 84 | }); 85 | } 86 | } 87 | 88 | if (validatedBody.removeItems && validatedBody.removeItems.length > 0) { 89 | const tmdbIdsToRemove = validatedBody.removeItems.map(item => item.tmdb_id); 90 | 91 | await tx.list_items.deleteMany({ 92 | where: { 93 | list_id: list.id, 94 | tmdb_id: { in: tmdbIdsToRemove }, 95 | }, 96 | }); 97 | } 98 | 99 | return tx.lists.findUnique({ 100 | where: { id: list.id }, 101 | include: { list_items: true }, 102 | }); 103 | }); 104 | 105 | return { 106 | list: result, 107 | message: 'List updated successfully', 108 | }; 109 | }); 110 | -------------------------------------------------------------------------------- /railpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.railpack.com", 3 | "caches": { 4 | "node-modules": { 5 | "directory": "/app/node_modules/.cache", 6 | "type": "shared" 7 | }, 8 | "npm-install": { 9 | "directory": "/root/.npm", 10 | "type": "shared" 11 | } 12 | }, 13 | "deploy": { 14 | "base": { 15 | "image": "ghcr.io/railwayapp/railpack-runtime:latest" 16 | }, 17 | "inputs": [ 18 | { 19 | "include": [ 20 | "/mise/shims", 21 | "/mise/installs", 22 | "/usr/local/bin/mise", 23 | "/etc/mise/config.toml", 24 | "/root/.local/state/mise" 25 | ], 26 | "step": "packages:mise" 27 | }, 28 | { 29 | "include": [ 30 | "/app/node_modules" 31 | ], 32 | "step": "build" 33 | }, 34 | { 35 | "exclude": [ 36 | "node_modules", 37 | ".yarn" 38 | ], 39 | "include": [ 40 | "/root/.cache", 41 | "." 42 | ], 43 | "step": "build" 44 | } 45 | ], 46 | "startCommand": "node .output/server/index.mjs", 47 | "variables": { 48 | "CI": "true", 49 | "NODE_ENV": "production", 50 | "NPM_CONFIG_FUND": "false", 51 | "NPM_CONFIG_PRODUCTION": "false", 52 | "NPM_CONFIG_UPDATE_NOTIFIER": "false" 53 | } 54 | }, 55 | "steps": { 56 | "packages:mise": { 57 | "assets": { 58 | "mise.toml": "[tools]\n [tools.node]\n version = \"22.20.0\"\n" 59 | }, 60 | "commands": [ 61 | { 62 | "path": "/mise/shims" 63 | }, 64 | { 65 | "customName": "create mise config", 66 | "name": "mise.toml", 67 | "path": "/etc/mise/config.toml" 68 | }, 69 | { 70 | "cmd": "sh -c 'mise trust -a \u0026\u0026 mise install'", 71 | "customName": "install mise packages: node" 72 | } 73 | ], 74 | "inputs": [ 75 | { 76 | "image": "ghcr.io/railwayapp/railpack-builder:latest" 77 | } 78 | ], 79 | "variables": { 80 | "MISE_CACHE_DIR": "/mise/cache", 81 | "MISE_CONFIG_DIR": "/mise", 82 | "MISE_DATA_DIR": "/mise", 83 | "MISE_INSTALLS_DIR": "/mise/installs", 84 | "MISE_NODE_VERIFY": "false", 85 | "MISE_SHIMS_DIR": "/mise/shims" 86 | } 87 | }, 88 | "install": { 89 | "caches": [ 90 | "npm-install" 91 | ], 92 | "commands": [ 93 | { 94 | "path": "/app/node_modules/.bin" 95 | }, 96 | { 97 | "cmd": "mkdir -p /app/node_modules/.cache" 98 | }, 99 | { 100 | "cmd": "npm ci" 101 | } 102 | ], 103 | "inputs": [ 104 | { 105 | "step": "packages:mise" 106 | }, 107 | { 108 | "include": [ 109 | "." 110 | ], 111 | "local": true 112 | } 113 | ], 114 | "variables": { 115 | "CI": "true", 116 | "NODE_ENV": "production", 117 | "NPM_CONFIG_FUND": "false", 118 | "NPM_CONFIG_PRODUCTION": "false", 119 | "NPM_CONFIG_UPDATE_NOTIFIER": "false" 120 | } 121 | }, 122 | "build": { 123 | "caches": [ 124 | "node-modules" 125 | ], 126 | "commands": [ 127 | { 128 | "cmd": "npm run build" 129 | } 130 | ], 131 | "inputs": [ 132 | { 133 | "step": "install" 134 | }, 135 | { 136 | "include": [ 137 | "." 138 | ], 139 | "local": true 140 | } 141 | ], 142 | "secrets": [ 143 | "*" 144 | ] 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BackendV2 2 | follow me on [GitHub](https://github.com/FifthWit) 3 | 4 | BackendV2 is a from scratch rewrite of movie-web's backend using [Nitro](https://nitro.build), and [Prisma](https://prisma.io). 5 | 6 | ## Deployment 7 | There are multiple supported ways to deploy BackendV2 based on your needs: 8 | ### NixPacks 9 | 1. Install NixPacks with 10 | ```sh 11 | # Mac 12 | brew install nixpacks 13 | # POSIX (mac, linux) 14 | curl -sSL https://nixpacks.com/install.sh | bash 15 | # Windows 16 | irm https://nixpacks.com/install.ps1 | iex 17 | ``` 18 | 2. Build the backend 19 | ```sh 20 | nixpacks build ./path/to/app --name my-app # my-app will be the container name aswell 21 | ``` 22 | 3. Run the container 23 | ```sh 24 | docker run my-app 25 | ``` 26 | > [!TIP] 27 | If you use a tool like Dokploy or Coolify, NixPacks support is out of the box 28 | ### Railpack 29 | Railpack is the successor to NixPacks, to run the backend via Railpack: 30 | 31 | 1. Install [Railpack](https://railpack.com/installation) 32 | 33 | 2. Run BuildKit and set BuildKit host 34 | ```sh 35 | docker run --rm --privileged -d --name buildkit moby/buildki 36 | 37 | export BUILDKIT_HOST='docker-container://buildkit' 38 | ``` 39 | 40 | 3. Build Backend 41 | ```sh 42 | cd ./path/to/backend 43 | railpack build . 44 | ``` 45 | 46 | 4. Run Backend container 47 | ```sh 48 | # Run manually 49 | docker run -it backend 50 | # Run in the background 51 | docker run -d -it backend 52 | ``` 53 | 54 | 5. Verify it's running 55 | ```sh 56 | docker ps 57 | # You should see backend, and buildkit running 58 | ``` 59 | 60 | ### Manually 61 | 1. Git clone the environment 62 | ```sh 63 | git clone https://github.com/p-stream/backend.git 64 | cd backend 65 | ``` 66 | 2. Build the backend 67 | ```sh 68 | npm install && npm run build 69 | ``` 70 | 3. Run the backend 71 | ```sh 72 | node .nitro/index.mjs 73 | ``` 74 | 75 | ## Setup your environment variables: 76 | To run the backend you need environment variables setup 77 | 1. Create .env file 78 | ```sh 79 | cp .env.example .env 80 | ``` 81 | 82 | 2. Fill in the values in the .env file 83 | 84 | > [!NOTE] 85 | > for postgres you may want to use a service like [Neon](https://neon.tech) or host your own with docker, to do that just look it up 86 | 87 | ## Contributing 88 | We love contributors, it helps the community so much, if you are interested in contributing here are some steps: 89 | 90 | 1. Clone the repo 91 | ```sh 92 | git clone https://github.com/p-stream/backend.git 93 | cd backend 94 | ``` 95 | 96 | 2. Install Deps/Run the backend 97 | ```sh 98 | npm install && npm run dev 99 | ``` 100 | 101 | 3. Set your Environment variables: check above as there is a guide for it! 102 | 103 | 4. Make your changes! Go crazy, so long as you think it is helpful we'd love to see a Pull Request, have fun, this project is FOSS and done in my our maintainers free time, no pressure, just enjoy yourself 104 | 105 | ### Philosophy/Habits for devs 106 | Here is a general rule of thumb for what your changes and developments should look like if you plan on getting it merged to the main branch: 107 | 108 | - Use Prettier & ESLint: We aren't going to be crazy if it's not well formatted but by using the extensions it keeps our code consistent, which makes it a lot easier for maintainers to help merge your code 109 | - Keep it minimal, things like Email are out of the question, we want to keep it small, if you think that it's **really** needed, make an issue on our GitHub to express your interest in it, and a maintainer will confirm or deny whether we would merge it 110 | - Understand our tech stack, this is a generic piece of advice but if you haven't use NitroJS for example, read their docs and make sure you're familiar with the framework, it makes your code quality much better, and makes reviewing much easier 111 | 112 | Star this repo and please follow me on [GitHub](https://github.com/FifthWit)! -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "es2022": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module", 16 | "project": "./tsconfig.json" 17 | }, 18 | "plugins": ["@typescript-eslint", "prettier"], 19 | "rules": { 20 | // Error prevention 21 | "no-console": ["warn", { "allow": ["warn", "error"] }], 22 | "no-debugger": "warn", 23 | "no-duplicate-imports": "error", 24 | "no-unused-vars": "off", 25 | "@typescript-eslint/no-unused-vars": [ 26 | "error", 27 | { 28 | "argsIgnorePattern": "^_", 29 | "varsIgnorePattern": "^_", 30 | "caughtErrorsIgnorePattern": "^_" 31 | } 32 | ], 33 | "@typescript-eslint/no-explicit-any": "warn", 34 | "@typescript-eslint/explicit-function-return-type": [ 35 | "warn", 36 | { 37 | "allowExpressions": true, 38 | "allowTypedFunctionExpressions": true 39 | } 40 | ], 41 | "@typescript-eslint/no-floating-promises": "error", 42 | "@typescript-eslint/no-misused-promises": "error", 43 | "@typescript-eslint/await-thenable": "error", 44 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 45 | "@typescript-eslint/prefer-nullish-coalescing": "error", 46 | "@typescript-eslint/prefer-optional-chain": "error", 47 | "@typescript-eslint/consistent-type-imports": [ 48 | "error", 49 | { 50 | "prefer": "type-imports", 51 | "disallowTypeAnnotations": false 52 | } 53 | ], 54 | 55 | // Code style 56 | "prettier/prettier": [ 57 | "error", 58 | { 59 | "singleQuote": true, 60 | "trailingComma": "es5", 61 | "printWidth": 100, 62 | "tabWidth": 2, 63 | "semi": true 64 | } 65 | ], 66 | "arrow-body-style": ["error", "as-needed"], 67 | "prefer-arrow-callback": "error", 68 | "no-var": "error", 69 | "prefer-const": "error", 70 | "eqeqeq": ["error", "always", { "null": "ignore" }], 71 | "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], 72 | "no-trailing-spaces": "error", 73 | "eol-last": "error", 74 | "comma-dangle": ["error", "always-multiline"], 75 | "quotes": ["error", "single", { "avoidEscape": true }], 76 | "semi": ["error", "always"], 77 | "object-curly-spacing": ["error", "always"], 78 | "array-bracket-spacing": ["error", "never"], 79 | "computed-property-spacing": ["error", "never"], 80 | "space-before-function-paren": [ 81 | "error", 82 | { 83 | "anonymous": "always", 84 | "named": "never", 85 | "asyncArrow": "always" 86 | } 87 | ], 88 | "space-before-blocks": ["error", "always"], 89 | "keyword-spacing": ["error", { "before": true, "after": true }], 90 | "space-infix-ops": "error", 91 | "no-multi-spaces": "error", 92 | "no-whitespace-before-property": "error", 93 | "func-call-spacing": ["error", "never"], 94 | "no-spaced-func": "error", 95 | "no-unexpected-multiline": "error", 96 | "no-mixed-spaces-and-tabs": "error", 97 | "no-tabs": "error", 98 | "indent": [ 99 | "error", 100 | 2, 101 | { 102 | "SwitchCase": 1, 103 | "FunctionDeclaration": { "parameters": "first" }, 104 | "FunctionExpression": { "parameters": "first" }, 105 | "CallExpression": { "arguments": "first" }, 106 | "ArrayExpression": "first", 107 | "ObjectExpression": "first" 108 | } 109 | ] 110 | }, 111 | "ignorePatterns": [ 112 | "node_modules/", 113 | "dist/", 114 | ".nuxt/", 115 | ".output/", 116 | "coverage/", 117 | "*.config.js", 118 | "*.config.ts" 119 | ] 120 | } 121 | -------------------------------------------------------------------------------- /server/api/player/README.md: -------------------------------------------------------------------------------- 1 | # Player Status API 2 | 3 | This API allows for tracking and retrieving player status data for users in watch party rooms. Status data is automatically cleaned up if it's older than 30 minutes. 4 | 5 | ## Endpoints 6 | 7 | ### POST `/api/player/status` 8 | 9 | Send a player status update. 10 | 11 | **Request Body:** 12 | 13 | ```json 14 | { 15 | "userId": "user123", // Required: User identifier 16 | "roomCode": "room456", // Required: Room code 17 | "isHost": true, // Optional: Whether the user is the host 18 | "content": { 19 | // Optional: Content information 20 | "title": "Movie Title", 21 | "type": "Movie", // "Movie", "TV Show", etc. 22 | "tmdbId": 12345, // Optional: TMDB ID for the content 23 | "seasonNumber": 1, // Optional: Season number (for TV shows) 24 | "episodeNumber": 3 // Optional: Episode number (for TV shows) 25 | }, 26 | "player": { 27 | // Optional: Player state 28 | "isPlaying": true, 29 | "isPaused": false, 30 | "isLoading": false, 31 | "hasPlayedOnce": true, 32 | "time": 120.5, // Current playback position in seconds 33 | "duration": 3600, // Total content duration in seconds 34 | "volume": 0.8, // Volume level (0-1) 35 | "playbackRate": 1, // Playback speed 36 | "buffered": 180 // Buffered seconds 37 | } 38 | } 39 | ``` 40 | 41 | **Response:** 42 | 43 | ```json 44 | { 45 | "success": true, 46 | "timestamp": 1625097600000 // The timestamp assigned to this status update 47 | } 48 | ``` 49 | 50 | ### GET `/api/player/status?userId=user123&roomCode=room456` 51 | 52 | Get status updates for a specific user in a specific room. 53 | 54 | **Query Parameters:** 55 | 56 | - `userId`: User identifier 57 | - `roomCode`: Room code 58 | 59 | **Response:** 60 | 61 | ```json 62 | { 63 | "userId": "user123", 64 | "roomCode": "room456", 65 | "statuses": [ 66 | { 67 | "userId": "user123", 68 | "roomCode": "room456", 69 | "isHost": true, 70 | "content": { 71 | "title": "Movie Title", 72 | "type": "Movie", 73 | "tmdbId": 12345, 74 | "seasonNumber": null, 75 | "episodeNumber": null 76 | }, 77 | "player": { 78 | "isPlaying": true, 79 | "isPaused": false, 80 | "isLoading": false, 81 | "hasPlayedOnce": true, 82 | "time": 120.5, 83 | "duration": 3600, 84 | "volume": 0.8, 85 | "playbackRate": 1, 86 | "buffered": 180 87 | }, 88 | "timestamp": 1625097600000 89 | } 90 | // More status updates if available 91 | ] 92 | } 93 | ``` 94 | 95 | ### GET `/api/player/status?roomCode=room456` 96 | 97 | Get status updates for all users in a specific room. 98 | 99 | **Query Parameters:** 100 | 101 | - `roomCode`: Room code 102 | 103 | **Response:** 104 | 105 | ```json 106 | { 107 | "roomCode": "room456", 108 | "users": { 109 | "user123": [ 110 | { 111 | "userId": "user123", 112 | "roomCode": "room456", 113 | "isHost": true, 114 | "content": { 115 | "title": "Show Title", 116 | "type": "TV Show", 117 | "tmdbId": 67890, 118 | "seasonNumber": 2, 119 | "episodeNumber": 5 120 | }, 121 | "player": { 122 | "isPlaying": true, 123 | "isPaused": false, 124 | "isLoading": false, 125 | "hasPlayedOnce": true, 126 | "time": 120.5, 127 | "duration": 3600, 128 | "volume": 0.8, 129 | "playbackRate": 1, 130 | "buffered": 180 131 | }, 132 | "timestamp": 1625097600000 133 | } 134 | // More status updates for this user if available 135 | ], 136 | "user456": [ 137 | // Status updates for another user 138 | ] 139 | } 140 | } 141 | ``` 142 | 143 | ## Notes 144 | 145 | - Status data is automatically cleaned up if it's older than 30 minutes 146 | - The system keeps a maximum of 5 status updates per user per room 147 | - Timestamps are in milliseconds since epoch (Unix timestamp) 148 | -------------------------------------------------------------------------------- /server/routes/users/[id]/index.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { scopedLogger } from '~/utils/logger'; 4 | 5 | const log = scopedLogger('user-profile'); 6 | 7 | const userProfileSchema = z.object({ 8 | profile: z.object({ 9 | icon: z.string(), 10 | colorA: z.string(), 11 | colorB: z.string(), 12 | }).optional(), 13 | nickname: z.string().min(1).max(255).optional(), 14 | }); 15 | 16 | export default defineEventHandler(async event => { 17 | const userId = event.context.params?.id; 18 | 19 | const session = await useAuth().getCurrentSession(); 20 | 21 | if (session.user !== userId) { 22 | throw createError({ 23 | statusCode: 403, 24 | message: 'Cannot modify other users', 25 | }); 26 | } 27 | 28 | if (event.method === 'PATCH') { 29 | try { 30 | const body = await readBody(event); 31 | log.info('Updating user profile', { userId, body }); 32 | 33 | const validatedBody = userProfileSchema.parse(body); 34 | 35 | const updateData: any = {}; 36 | if (validatedBody.profile) { 37 | updateData.profile = validatedBody.profile; 38 | } 39 | if (validatedBody.nickname !== undefined) { 40 | updateData.nickname = validatedBody.nickname; 41 | } 42 | 43 | const user = await prisma.users.update({ 44 | where: { id: userId }, 45 | data: updateData, 46 | }); 47 | 48 | log.info('User profile updated successfully', { userId }); 49 | 50 | return { 51 | id: user.id, 52 | publicKey: user.public_key, 53 | namespace: user.namespace, 54 | nickname: (user as any).nickname, 55 | profile: user.profile, 56 | permissions: user.permissions, 57 | createdAt: user.created_at, 58 | lastLoggedIn: user.last_logged_in, 59 | }; 60 | } catch (error) { 61 | log.error('Failed to update user profile', { 62 | userId, 63 | error: error instanceof Error ? error.message : String(error), 64 | }); 65 | 66 | if (error instanceof z.ZodError) { 67 | throw createError({ 68 | statusCode: 400, 69 | message: 'Invalid profile data', 70 | cause: error.errors, 71 | }); 72 | } 73 | 74 | throw createError({ 75 | statusCode: 500, 76 | message: 'Failed to update user profile', 77 | cause: error instanceof Error ? error.message : 'Unknown error', 78 | }); 79 | } 80 | } 81 | 82 | if (event.method === 'DELETE') { 83 | try { 84 | log.info('Deleting user account', { userId }); 85 | 86 | // Delete related records first 87 | await prisma.$transaction(async tx => { 88 | // Delete user bookmarks 89 | await tx.bookmarks.deleteMany({ 90 | where: { user_id: userId }, 91 | }); 92 | 93 | await tx.progress_items.deleteMany({ 94 | where: { user_id: userId }, 95 | }); 96 | 97 | await tx.user_settings 98 | .delete({ 99 | where: { id: userId }, 100 | }) 101 | .catch(() => {}); 102 | 103 | await tx.sessions.deleteMany({ 104 | where: { user: userId }, 105 | }); 106 | 107 | await tx.users.delete({ 108 | where: { id: userId }, 109 | }); 110 | }); 111 | 112 | log.info('User account deleted successfully', { userId }); 113 | 114 | return { success: true, message: 'User account deleted successfully' }; 115 | } catch (error) { 116 | log.error('Failed to delete user account', { 117 | userId, 118 | error: error instanceof Error ? error.message : String(error), 119 | }); 120 | 121 | throw createError({ 122 | statusCode: 500, 123 | message: 'Failed to delete user account', 124 | cause: error instanceof Error ? error.message : 'Unknown error', 125 | }); 126 | } 127 | } 128 | 129 | throw createError({ 130 | statusCode: 405, 131 | message: 'Method not allowed', 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /server/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from './prisma'; 2 | import jwt from 'jsonwebtoken'; 3 | const { sign, verify } = jwt; 4 | import { randomUUID } from 'crypto'; 5 | 6 | // 21 days in ms 7 | const SESSION_EXPIRY_MS = 21 * 24 * 60 * 60 * 1000; 8 | 9 | export function useAuth() { 10 | const getSession = async (id: string) => { 11 | const session = await prisma.sessions.findUnique({ 12 | where: { id }, 13 | }); 14 | 15 | if (!session) return null; 16 | if (new Date(session.expires_at) < new Date()) return null; 17 | 18 | return session; 19 | }; 20 | 21 | const getSessionAndBump = async (id: string) => { 22 | const session = await getSession(id); 23 | if (!session) return null; 24 | 25 | const now = new Date(); 26 | const expiryDate = new Date(now.getTime() + SESSION_EXPIRY_MS); 27 | 28 | return await prisma.sessions.update({ 29 | where: { id }, 30 | data: { 31 | accessed_at: now, 32 | expires_at: expiryDate, 33 | }, 34 | }); 35 | }; 36 | 37 | const makeSession = async (user: string, device: string, userAgent?: string) => { 38 | if (!userAgent) throw new Error('No useragent provided'); 39 | 40 | const now = new Date(); 41 | const expiryDate = new Date(now.getTime() + SESSION_EXPIRY_MS); 42 | 43 | return await prisma.sessions.create({ 44 | data: { 45 | id: randomUUID(), 46 | user, 47 | device, 48 | user_agent: userAgent, 49 | created_at: now, 50 | accessed_at: now, 51 | expires_at: expiryDate, 52 | }, 53 | }); 54 | }; 55 | 56 | const makeSessionToken = (session: { id: string }) => { 57 | const runtimeConfig = useRuntimeConfig(); 58 | const cryptoSecret = runtimeConfig.cryptoSecret || process.env.CRYPTO_SECRET; 59 | 60 | if (!cryptoSecret) { 61 | console.error('CRYPTO_SECRET is missing from both runtime config and environment'); 62 | console.error('Available runtime config keys:', Object.keys(runtimeConfig)); 63 | console.error('Environment variables:', { 64 | CRYPTO_SECRET: process.env.CRYPTO_SECRET ? 'SET' : 'NOT SET', 65 | NODE_ENV: process.env.NODE_ENV, 66 | }); 67 | throw new Error('CRYPTO_SECRET environment variable is not set'); 68 | } 69 | 70 | return sign({ sid: session.id }, cryptoSecret, { 71 | algorithm: 'HS256', 72 | }); 73 | }; 74 | 75 | const verifySessionToken = (token: string) => { 76 | try { 77 | const runtimeConfig = useRuntimeConfig(); 78 | const cryptoSecret = runtimeConfig.cryptoSecret || process.env.CRYPTO_SECRET; 79 | 80 | if (!cryptoSecret) { 81 | console.error('CRYPTO_SECRET is missing for token verification'); 82 | return null; 83 | } 84 | 85 | const payload = verify(token, cryptoSecret, { 86 | algorithms: ['HS256'], 87 | }); 88 | 89 | if (typeof payload === 'string') return null; 90 | return payload as { sid: string }; 91 | } catch { 92 | return null; 93 | } 94 | }; 95 | 96 | const getCurrentSession = async () => { 97 | const event = useEvent(); 98 | const authHeader = getRequestHeader(event, 'authorization'); 99 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 100 | throw createError({ 101 | statusCode: 401, 102 | message: 'Unauthorized', 103 | }); 104 | } 105 | 106 | const token = authHeader.split(' ')[1]; 107 | const payload = verifySessionToken(token); 108 | if (!payload) { 109 | throw createError({ 110 | statusCode: 401, 111 | message: 'Invalid token', 112 | }); 113 | } 114 | 115 | const session = await getSessionAndBump(payload.sid); 116 | if (!session) { 117 | throw createError({ 118 | statusCode: 401, 119 | message: 'Session not found or expired', 120 | }); 121 | } 122 | 123 | return session; 124 | }; 125 | 126 | return { 127 | getSession, 128 | getSessionAndBump, 129 | makeSession, 130 | makeSessionToken, 131 | verifySessionToken, 132 | getCurrentSession, 133 | }; 134 | } 135 | -------------------------------------------------------------------------------- /server/routes/discover/index.ts: -------------------------------------------------------------------------------- 1 | import { TMDB } from 'tmdb-ts'; 2 | const tmdb = new TMDB(useRuntimeConfig().tmdbApiKey); 3 | import { trakt } from '#imports'; 4 | 5 | export default defineCachedEventHandler( 6 | async event => { 7 | const popular = { movies: [], shows: [] }; 8 | popular.movies.push( 9 | ...(data => (data.results.sort((a, b) => b.vote_average - a.vote_average), data.results))( 10 | await tmdb.movies.popular() 11 | ) 12 | ); // Sorts by vote average 13 | popular.shows.push( 14 | ...(data => (data.results.sort((a, b) => b.vote_average - a.vote_average), data.results))( 15 | await tmdb.tvShows.popular() 16 | ) 17 | ); // Sorts by vote average 18 | 19 | const genres = { 20 | movies: await tmdb.genres.movies(), 21 | shows: await tmdb.genres.tvShows(), 22 | }; 23 | const topRated = { 24 | movies: await tmdb.movies.topRated(), 25 | shows: await tmdb.tvShows.topRated(), 26 | }; 27 | const nowPlaying = { 28 | movies: (await tmdb.movies.nowPlaying()).results.sort( 29 | (a, b) => b.vote_average - a.vote_average 30 | ), 31 | shows: (await tmdb.tvShows.onTheAir()).results.sort( 32 | (a, b) => b.vote_average - a.vote_average 33 | ), 34 | }; 35 | let lists = []; 36 | 37 | const internalLists = { 38 | trending: await trakt.lists.trending(), 39 | popular: await trakt.lists.popular(), 40 | }; 41 | 42 | for (let list = 0; list < internalLists.trending.length; list++) { 43 | const items = await trakt.lists.items({ 44 | id: internalLists.trending[list].list.ids.trakt, 45 | type: 'all', 46 | }); 47 | lists.push({ 48 | name: internalLists.trending[list].list.name, 49 | likes: internalLists.trending[list].like_count, 50 | items: [], 51 | }); 52 | for (let item = 0; item < items.length; item++) { 53 | switch (true) { 54 | case !!items[item].movie?.ids?.tmdb: 55 | lists[list].items.push({ 56 | type: 'movie', 57 | name: items[item].movie.title, 58 | id: items[item].movie.ids.tmdb, 59 | year: items[item].movie.year, 60 | }); 61 | break; 62 | case !!items[item].show?.ids?.tmdb: 63 | lists[list].items.push({ 64 | type: 'show', 65 | name: items[item].show.title, 66 | id: items[item].show.ids.tmdb, 67 | year: items[item].show.year, 68 | }); 69 | break; 70 | } 71 | } 72 | } 73 | 74 | for (let list = 0; list < internalLists.popular.length; list++) { 75 | const items = await trakt.lists.items({ 76 | id: internalLists.popular[list].list.ids.trakt, 77 | type: 'all', 78 | }); 79 | lists.push({ 80 | name: internalLists.popular[list].list.name, 81 | likes: internalLists.popular[list].like_count, 82 | items: [], 83 | }); 84 | for (let item = 0; item < items.length; item++) { 85 | switch (true) { 86 | case !!items[item].movie?.ids?.tmdb: 87 | lists[lists.length - 1].items.push({ 88 | type: 'movie', 89 | name: items[item].movie.title, 90 | id: items[item].movie.ids.tmdb, 91 | year: items[item].movie.year, 92 | }); 93 | break; 94 | case !!items[item].show?.ids?.tmdb: 95 | lists[lists.length - 1].items.push({ 96 | type: 'show', 97 | name: items[item].show.title, 98 | id: items[item].show.ids.tmdb, 99 | year: items[item].show.year, 100 | }); 101 | break; 102 | } 103 | } 104 | } 105 | const trending = await trakt.movies.popular(); 106 | 107 | // most watched films 108 | const mostWatched = await trakt.movies.watched(); 109 | // takes the highest grossing box office film in the last weekend 110 | const lastWeekend = await trakt.movies.boxoffice(); 111 | 112 | return { 113 | mostWatched, 114 | lastWeekend, 115 | trending, 116 | popular, 117 | topRated, 118 | nowPlaying, 119 | genres, 120 | traktLists: lists, 121 | }; 122 | }, 123 | { 124 | maxAge: process.env.NODE_ENV === 'production' ? 60 * 60 : 0, // 20 Minutes for prod, no cache for dev. Customize to your liking 125 | } 126 | ); 127 | -------------------------------------------------------------------------------- /server/routes/users/[id]/progress/[tmdb_id]/index.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { randomUUID } from 'crypto'; 4 | 5 | const progressMetaSchema = z.object({ 6 | title: z.string(), 7 | poster: z.string().optional(), 8 | type: z.enum(['movie', 'tv', 'show']), 9 | year: z.number().optional(), 10 | }); 11 | 12 | const progressItemSchema = z.object({ 13 | meta: progressMetaSchema, 14 | tmdbId: z.string(), 15 | duration: z.number().transform(Math.round), 16 | watched: z.number().transform(Math.round), 17 | seasonId: z.string().optional(), 18 | episodeId: z.string().optional(), 19 | seasonNumber: z.number().optional(), 20 | episodeNumber: z.number().optional(), 21 | updatedAt: z.string().datetime({ offset: true }).optional(), 22 | }); 23 | 24 | // 13th July 2021 - movie-web epoch 25 | const minEpoch = 1626134400000; 26 | 27 | const coerceDateTime = (dateTime?: string) => { 28 | const epoch = dateTime ? new Date(dateTime).getTime() : Date.now(); 29 | return new Date(Math.max(minEpoch, Math.min(epoch, Date.now()))); 30 | }; 31 | 32 | const normalizeIds = (metaType: string, seasonId?: string, episodeId?: string) => ({ 33 | seasonId: metaType === 'movie' ? '\n' : seasonId || null, 34 | episodeId: metaType === 'movie' ? '\n' : episodeId || null, 35 | }); 36 | 37 | const formatProgressItem = (item: any) => ({ 38 | id: item.id, 39 | tmdbId: item.tmdb_id, 40 | userId: item.user_id, 41 | seasonId: item.season_id === '\n' ? null : item.season_id, 42 | episodeId: item.episode_id === '\n' ? null : item.episode_id, 43 | seasonNumber: item.season_number, 44 | episodeNumber: item.episode_number, 45 | meta: item.meta, 46 | duration: Number(item.duration), 47 | watched: Number(item.watched), 48 | updatedAt: item.updated_at, 49 | }); 50 | 51 | export default defineEventHandler(async (event) => { 52 | const { id: userId, tmdb_id: tmdbId } = event.context.params!; 53 | const method = event.method; 54 | 55 | const session = await useAuth().getCurrentSession(); 56 | if (session.user !== userId) { 57 | throw createError({ statusCode: 403, message: 'Unauthorized' }); 58 | } 59 | 60 | if (method === 'PUT') { 61 | const body = await readBody(event); 62 | let parsedBody; 63 | try { 64 | parsedBody = progressItemSchema.parse(body); 65 | } catch (error) { 66 | throw createError({ statusCode: 400, message: error.message }); 67 | } 68 | const { meta, tmdbId, duration, watched, seasonId, episodeId, seasonNumber, episodeNumber, updatedAt } = parsedBody; 69 | 70 | const now = coerceDateTime(updatedAt); 71 | const { seasonId: normSeasonId, episodeId: normEpisodeId } = normalizeIds(meta.type, seasonId, episodeId); 72 | 73 | const existing = await prisma.progress_items.findUnique({ 74 | where: { tmdb_id_user_id_season_id_episode_id: { tmdb_id: tmdbId, user_id: userId, season_id: normSeasonId, episode_id: normEpisodeId } }, 75 | }); 76 | 77 | const data = { 78 | duration: BigInt(duration), 79 | watched: BigInt(watched), 80 | meta, 81 | updated_at: now, 82 | }; 83 | 84 | const progressItem = existing 85 | ? await prisma.progress_items.update({ where: { id: existing.id }, data }) 86 | : await prisma.progress_items.create({ 87 | data: { 88 | id: randomUUID(), 89 | tmdb_id: tmdbId, 90 | user_id: userId, 91 | season_id: normSeasonId, 92 | episode_id: normEpisodeId, 93 | season_number: seasonNumber || null, 94 | episode_number: episodeNumber || null, 95 | ...data, 96 | }, 97 | }); 98 | 99 | return formatProgressItem(progressItem); 100 | } 101 | 102 | if (method === 'DELETE') { 103 | const body = await readBody(event).catch(() => ({})); 104 | const where: any = { user_id: userId, tmdb_id: tmdbId }; 105 | 106 | if (body.seasonId) where.season_id = body.seasonId; 107 | else if (body.meta?.type === 'movie') where.season_id = '\n'; 108 | 109 | if (body.episodeId) where.episode_id = body.episodeId; 110 | else if (body.meta?.type === 'movie') where.episode_id = '\n'; 111 | 112 | const items = await prisma.progress_items.findMany({ where }); 113 | if (items.length === 0) return { count: 0, tmdbId, episodeId: body.episodeId, seasonId: body.seasonId }; 114 | 115 | await prisma.progress_items.deleteMany({ where }); 116 | return { count: items.length, tmdbId, episodeId: body.episodeId, seasonId: body.seasonId }; 117 | } 118 | 119 | throw createError({ statusCode: 405, message: 'Method not allowed' }); 120 | }); 121 | -------------------------------------------------------------------------------- /server/routes/letterboxd/index.get.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import { TMDB } from 'tmdb-ts'; 3 | const tmdb = new TMDB(useRuntimeConfig().tmdbApiKey); 4 | 5 | export default defineCachedEventHandler(async (event) => { 6 | try { 7 | const response = await fetch('https://letterboxd.com/lists/'); 8 | let html = await response.text(); 9 | 10 | html = html.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); 11 | 12 | const $ = cheerio.load(html); 13 | 14 | const listItems = $('a.list-link').map((i, el) => ({ 15 | href: $(el).attr('href'), 16 | title: $(el).find('.list-name').text().trim() || $(el).attr('title'), 17 | text: $(el).text().trim() 18 | })).get(); 19 | 20 | if (!listItems.length) { 21 | return { 22 | lists: [], 23 | error: 'No lists found' 24 | }; 25 | } 26 | 27 | const allLists = []; 28 | 29 | for (let i = 0; i < listItems.length; i++) { 30 | const listItem = listItems[i]; 31 | 32 | if (!listItem.href) { 33 | continue; 34 | } 35 | 36 | try { 37 | const listUrl = `https://letterboxd.com${listItem.href}`; 38 | const listResponse = await fetch(listUrl); 39 | let listHtml = await listResponse.text(); 40 | 41 | listHtml = listHtml.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); 42 | 43 | const list$ = cheerio.load(listHtml); 44 | 45 | const ogTitle = list$('meta[property="og:title"]').attr('content'); 46 | const listName = ogTitle || listItem.title; 47 | 48 | const listStatsText = list$('.list-meta .stats').text(); 49 | const itemCountMatch = listStatsText.match(/(\d+)\s*film/i); 50 | const expectedItemCount = itemCountMatch ? parseInt(itemCountMatch[1]) : null; 51 | 52 | const possibleFilmSelectors = [ 53 | 'li.poster-container', 54 | '.poster-container', 55 | '.film-poster', 56 | '.poster', 57 | 'li[data-film-slug]', 58 | '[data-film-slug]', 59 | '.listitem', 60 | '.list-item' 61 | ]; 62 | 63 | let films = []; 64 | let workingSelector = ''; 65 | 66 | for (const selector of possibleFilmSelectors) { 67 | const elements = list$(selector); 68 | 69 | if (elements.length > 0) { 70 | workingSelector = selector; 71 | films = elements.map((i, el) => { 72 | const filmSlug = list$(el).attr('data-film-slug'); 73 | const targetLink = list$(el).attr('data-target-link'); 74 | const filmId = list$(el).attr('data-film-id'); 75 | 76 | const filmName = filmSlug 77 | ? filmSlug.split('-').map(word => 78 | word.charAt(0).toUpperCase() + word.slice(1) 79 | ).join(' ') 80 | : targetLink?.split('/film/')[1]?.split('/')[0]?.replace(/-/g, ' '); 81 | 82 | return { 83 | name: filmName, 84 | slug: filmSlug, 85 | link: targetLink, 86 | filmId: filmId 87 | }; 88 | }).get().filter(film => film.name); 89 | 90 | if (films.length > 0) { 91 | break; 92 | } 93 | } 94 | } 95 | 96 | const tmdbMovies = []; 97 | 98 | for (let j = 0; j < films.length; j++) { 99 | const film = films[j]; 100 | 101 | try { 102 | const searchResult = await tmdb.search.movies({ query: film.name }); 103 | 104 | if (searchResult.results && searchResult.results.length > 0) { 105 | const tmdbMovie = searchResult.results[0]; 106 | tmdbMovies.push(tmdbMovie); 107 | } 108 | } catch (error) { 109 | continue; 110 | } 111 | } 112 | 113 | allLists.push({ 114 | listName: listName, 115 | listUrl: listUrl, 116 | tmdbMovies, 117 | metadata: { 118 | originalFilmCount: films.length, 119 | foundTmdbMovies: tmdbMovies.length, 120 | expectedItemCount: expectedItemCount, 121 | workingSelector 122 | } 123 | }); 124 | 125 | } catch (error) { 126 | allLists.push({ 127 | listName: listItem.title, 128 | listUrl: `https://letterboxd.com${listItem.href}`, 129 | tmdbMovies: [], 130 | metadata: { 131 | originalFilmCount: 0, 132 | foundTmdbMovies: 0, 133 | expectedItemCount: null, 134 | error: 'Failed to process list' 135 | } 136 | }); 137 | } 138 | } 139 | 140 | return { 141 | lists: allLists, 142 | totalLists: allLists.length, 143 | summary: { 144 | totalTmdbMovies: allLists.reduce((sum, list) => sum + list.tmdbMovies.length, 0), 145 | totalExpectedItems: allLists.reduce((sum, list) => sum + (list.metadata?.expectedItemCount || 0), 0) 146 | } 147 | }; 148 | } catch (error) { 149 | throw createError({ 150 | statusCode: 500, 151 | statusMessage: 'Failed to fetch Letterboxd lists' 152 | }); 153 | } 154 | },{ 155 | maxAge: process.env.NODE_ENV === 'production' ? 60 * 60 * 24 : 0 156 | }); -------------------------------------------------------------------------------- /server/routes/users/[id]/watch-history/[tmdbid]/index.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { randomUUID } from 'crypto'; 4 | 5 | const watchHistoryMetaSchema = z.object({ 6 | title: z.string(), 7 | year: z.number().optional(), 8 | poster: z.string().optional(), 9 | type: z.enum(['movie', 'show']), 10 | }); 11 | 12 | const watchHistoryItemSchema = z.object({ 13 | meta: watchHistoryMetaSchema, 14 | tmdbId: z.string(), 15 | duration: z.number().transform(n => n.toString()), 16 | watched: z.number().transform(n => n.toString()), 17 | watchedAt: z.string().datetime({ offset: true }), 18 | completed: z.boolean().optional().default(false), 19 | seasonId: z.string().optional(), 20 | episodeId: z.string().optional(), 21 | seasonNumber: z.number().optional(), 22 | episodeNumber: z.number().optional(), 23 | }); 24 | 25 | // 13th July 2021 - movie-web epoch 26 | const minEpoch = 1626134400000; 27 | 28 | function defaultAndCoerceDateTime(dateTime: string | undefined) { 29 | const epoch = dateTime ? new Date(dateTime).getTime() : Date.now(); 30 | const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now())); 31 | return new Date(clampedEpoch); 32 | } 33 | 34 | export default defineEventHandler(async event => { 35 | const userId = event.context.params?.id; 36 | const tmdbId = event.context.params?.tmdbid; 37 | const method = event.method; 38 | 39 | const session = await useAuth().getCurrentSession(); 40 | if (!session) { 41 | throw createError({ 42 | statusCode: 401, 43 | message: 'Session not found or expired', 44 | }); 45 | } 46 | 47 | if (session.user !== userId) { 48 | throw createError({ 49 | statusCode: 403, 50 | message: 'Cannot access other user information', 51 | }); 52 | } 53 | 54 | if (method === 'PUT') { 55 | const body = await readBody(event); 56 | const validatedBody = watchHistoryItemSchema.parse(body); 57 | 58 | const watchedAt = defaultAndCoerceDateTime(validatedBody.watchedAt); 59 | const now = new Date(); 60 | 61 | const existingItem = await prisma.watch_history.findUnique({ 62 | where: { 63 | tmdb_id_user_id_season_id_episode_id: { 64 | tmdb_id: tmdbId, 65 | user_id: userId, 66 | season_id: validatedBody.seasonId || null, 67 | episode_id: validatedBody.episodeId || null, 68 | }, 69 | }, 70 | }); 71 | 72 | let watchHistoryItem; 73 | 74 | if (existingItem) { 75 | watchHistoryItem = await prisma.watch_history.update({ 76 | where: { 77 | id: existingItem.id, 78 | }, 79 | data: { 80 | duration: BigInt(validatedBody.duration), 81 | watched: BigInt(validatedBody.watched), 82 | watched_at: watchedAt, 83 | completed: validatedBody.completed, 84 | meta: validatedBody.meta, 85 | updated_at: now, 86 | }, 87 | }); 88 | } else { 89 | watchHistoryItem = await prisma.watch_history.create({ 90 | data: { 91 | id: randomUUID(), 92 | tmdb_id: tmdbId, 93 | user_id: userId, 94 | season_id: validatedBody.seasonId || null, 95 | episode_id: validatedBody.episodeId || null, 96 | season_number: validatedBody.seasonNumber || null, 97 | episode_number: validatedBody.episodeNumber || null, 98 | duration: BigInt(validatedBody.duration), 99 | watched: BigInt(validatedBody.watched), 100 | watched_at: watchedAt, 101 | completed: validatedBody.completed, 102 | meta: validatedBody.meta, 103 | updated_at: now, 104 | }, 105 | }); 106 | } 107 | 108 | return { 109 | success: true, 110 | id: watchHistoryItem.id, 111 | tmdbId: watchHistoryItem.tmdb_id, 112 | userId: watchHistoryItem.user_id, 113 | seasonId: watchHistoryItem.season_id, 114 | episodeId: watchHistoryItem.episode_id, 115 | seasonNumber: watchHistoryItem.season_number, 116 | episodeNumber: watchHistoryItem.episode_number, 117 | meta: watchHistoryItem.meta, 118 | duration: Number(watchHistoryItem.duration), 119 | watched: Number(watchHistoryItem.watched), 120 | watchedAt: watchHistoryItem.watched_at.toISOString(), 121 | completed: watchHistoryItem.completed, 122 | updatedAt: watchHistoryItem.updated_at.toISOString(), 123 | }; 124 | } 125 | 126 | if (method === 'DELETE') { 127 | const body = await readBody(event).catch(() => ({})); 128 | 129 | const whereClause: any = { 130 | user_id: userId, 131 | tmdb_id: tmdbId, 132 | }; 133 | 134 | if (body.seasonId) whereClause.season_id = body.seasonId; 135 | if (body.episodeId) whereClause.episode_id = body.episodeId; 136 | 137 | const itemsToDelete = await prisma.watch_history.findMany({ 138 | where: whereClause, 139 | }); 140 | 141 | if (itemsToDelete.length === 0) { 142 | return { 143 | success: true, 144 | count: 0, 145 | tmdbId, 146 | episodeId: body.episodeId, 147 | seasonId: body.seasonId, 148 | }; 149 | } 150 | 151 | await prisma.watch_history.deleteMany({ 152 | where: whereClause, 153 | }); 154 | 155 | return { 156 | success: true, 157 | count: itemsToDelete.length, 158 | tmdbId, 159 | episodeId: body.episodeId, 160 | seasonId: body.seasonId, 161 | }; 162 | } 163 | 164 | throw createError({ 165 | statusCode: 405, 166 | message: 'Method not allowed', 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /examples/player-status-integration.ts: -------------------------------------------------------------------------------- 1 | // Example frontend implementation for the player status API 2 | 3 | /** 4 | * Function to send player status to the backend 5 | */ 6 | export async function sendPlayerStatus({ 7 | userId, 8 | roomCode, 9 | isHost, 10 | content, 11 | player, 12 | }: { 13 | userId: string; 14 | roomCode: string; 15 | isHost: boolean; 16 | content: { 17 | title: string; 18 | type: string; 19 | }; 20 | player: { 21 | isPlaying: boolean; 22 | isPaused: boolean; 23 | isLoading: boolean; 24 | hasPlayedOnce: boolean; 25 | time: number; 26 | duration: number; 27 | volume: number; 28 | playbackRate: number; 29 | buffered: number; 30 | }; 31 | }) { 32 | try { 33 | const response = await fetch('/api/player/status', { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | body: JSON.stringify({ 39 | userId, 40 | roomCode, 41 | isHost, 42 | content, 43 | player, 44 | }), 45 | }); 46 | 47 | if (!response.ok) { 48 | throw new Error(`Failed to send player status: ${response.statusText}`); 49 | } 50 | 51 | const data = await response.json(); 52 | console.log('Successfully sent player status update', data); 53 | return data; 54 | } catch (error) { 55 | console.error('Error sending player status:', error); 56 | throw error; 57 | } 58 | } 59 | 60 | /** 61 | * Function to get player status for a specific user in a room 62 | */ 63 | export async function getPlayerStatus(userId: string, roomCode: string) { 64 | try { 65 | const response = await fetch(`/api/player/status?userId=${userId}&roomCode=${roomCode}`); 66 | 67 | if (!response.ok) { 68 | throw new Error(`Failed to get player status: ${response.statusText}`); 69 | } 70 | 71 | const data = await response.json(); 72 | console.log('Retrieved player status data:', data); 73 | return data; 74 | } catch (error) { 75 | console.error('Error getting player status:', error); 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Function to get status for all users in a room 82 | */ 83 | export async function getRoomStatuses(roomCode: string) { 84 | try { 85 | const response = await fetch(`/api/player/status?roomCode=${roomCode}`); 86 | 87 | if (!response.ok) { 88 | throw new Error(`Failed to get room statuses: ${response.statusText}`); 89 | } 90 | 91 | const data = await response.json(); 92 | console.log('Retrieved room statuses data:', data); 93 | return data; 94 | } catch (error) { 95 | console.error('Error getting room statuses:', error); 96 | throw error; 97 | } 98 | } 99 | 100 | /** 101 | * Example implementation for updating WebhookReporter to use the API 102 | */ 103 | export function ModifiedWebhookReporter() { 104 | // Example replacing the Discord webhook code 105 | /* 106 | useEffect(() => { 107 | // Skip if watch party is not enabled or no status 108 | if (!watchPartyEnabled || !latestStatus || !latestStatus.hasPlayedOnce) return; 109 | 110 | const now = Date.now(); 111 | 112 | // Create a state fingerprint to detect meaningful changes 113 | const stateFingerprint = JSON.stringify({ 114 | isPlaying: latestStatus.isPlaying, 115 | isPaused: latestStatus.isPaused, 116 | isLoading: latestStatus.isLoading, 117 | time: Math.floor(latestStatus.time / 5) * 5, // Round to nearest 5 seconds 118 | volume: Math.round(latestStatus.volume * 100), 119 | playbackRate: latestStatus.playbackRate, 120 | }); 121 | 122 | // Check if state has changed meaningfully AND 123 | // it's been at least 5 seconds since last report 124 | const hasStateChanged = stateFingerprint !== lastReportedStateRef.current; 125 | const timeThresholdMet = now - lastReportTime.current >= 5000; 126 | 127 | if (!hasStateChanged && !timeThresholdMet) return; 128 | 129 | let contentTitle = "Unknown content"; 130 | let contentType = ""; 131 | 132 | if (meta) { 133 | if (meta.type === "movie") { 134 | contentTitle = meta.title; 135 | contentType = "Movie"; 136 | } else if (meta.type === "show" && meta.episode) { 137 | contentTitle = `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}`; 138 | contentType = "TV Show"; 139 | } 140 | } 141 | 142 | // Send to our backend instead of Discord 143 | const sendToBackend = async () => { 144 | try { 145 | await sendPlayerStatus({ 146 | userId, 147 | roomCode: roomCode || 'none', 148 | isHost: isHost || false, 149 | content: { 150 | title: contentTitle, 151 | type: contentType || 'Unknown', 152 | }, 153 | player: { 154 | isPlaying: latestStatus.isPlaying, 155 | isPaused: latestStatus.isPaused, 156 | isLoading: latestStatus.isLoading, 157 | hasPlayedOnce: latestStatus.hasPlayedOnce, 158 | time: latestStatus.time, 159 | duration: latestStatus.duration, 160 | volume: latestStatus.volume, 161 | playbackRate: latestStatus.playbackRate, 162 | buffered: latestStatus.buffered, 163 | }, 164 | }); 165 | 166 | // Update last report time and fingerprint 167 | lastReportTime.current = now; 168 | lastReportedStateRef.current = stateFingerprint; 169 | 170 | console.log("Sent player status update to backend", { 171 | time: new Date().toISOString(), 172 | isPlaying: latestStatus.isPlaying, 173 | currentTime: Math.floor(latestStatus.time), 174 | userId, 175 | content: contentTitle, 176 | roomCode, 177 | }); 178 | } catch (error) { 179 | console.error("Failed to send player status to backend", error); 180 | } 181 | }; 182 | 183 | sendToBackend(); 184 | }, [ 185 | latestStatus, 186 | statusHistory.length, 187 | userId, 188 | account, 189 | meta, 190 | watchPartyEnabled, 191 | roomCode, 192 | isHost, 193 | ]); 194 | */ 195 | } 196 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client" 3 | output = "../generated" 4 | moduleFormat = "esm" 5 | } 6 | 7 | datasource db { 8 | provider = "postgresql" 9 | } 10 | 11 | model bookmarks { 12 | tmdb_id String @db.VarChar(255) 13 | user_id String @db.VarChar(255) 14 | meta Json 15 | updated_at DateTime @db.Timestamptz(0) 16 | group String[] 17 | favorite_episodes String[] @default([]) 18 | 19 | @@id([tmdb_id, user_id]) 20 | @@unique([tmdb_id, user_id], map: "bookmarks_tmdb_id_user_id_unique") 21 | } 22 | 23 | model challenge_codes { 24 | code String @id @db.Uuid 25 | flow String 26 | auth_type String @db.VarChar(255) 27 | created_at DateTime @db.Timestamptz(0) 28 | expires_at DateTime @db.Timestamptz(0) 29 | } 30 | 31 | model mikro_orm_migrations { 32 | id Int @id @default(autoincrement()) 33 | name String? @db.VarChar(255) 34 | executed_at DateTime? @default(now()) @db.Timestamptz(6) 35 | } 36 | 37 | model progress_items { 38 | id String @id @db.Uuid 39 | tmdb_id String @db.VarChar(255) 40 | user_id String @db.VarChar(255) 41 | season_id String? @db.VarChar(255) 42 | episode_id String? @db.VarChar(255) 43 | meta Json 44 | updated_at DateTime @db.Timestamptz(0) 45 | duration BigInt 46 | watched BigInt 47 | season_number Int? 48 | episode_number Int? 49 | 50 | @@unique([tmdb_id, user_id, season_id, episode_id], map: "progress_items_tmdb_id_user_id_season_id_episode_id_unique") 51 | } 52 | 53 | model sessions { 54 | id String @id @db.Uuid 55 | user String 56 | created_at DateTime @db.Timestamptz(0) 57 | accessed_at DateTime @db.Timestamptz(0) 58 | expires_at DateTime @db.Timestamptz(0) 59 | device String 60 | user_agent String 61 | } 62 | 63 | model user_settings { 64 | id String @id 65 | application_theme String? @db.VarChar(255) 66 | application_language String? @db.VarChar(255) 67 | default_subtitle_language String? @db.VarChar(255) 68 | proxy_urls String[] 69 | trakt_key String? @db.VarChar(255) 70 | febbox_key String? @db.VarChar(255) 71 | debrid_token String? @db.VarChar(255) 72 | debrid_service String? @db.VarChar(255) 73 | enable_thumbnails Boolean @default(false) 74 | enable_autoplay Boolean @default(true) 75 | enable_skip_credits Boolean @default(true) 76 | enable_discover Boolean @default(true) 77 | enable_featured Boolean @default(false) 78 | enable_details_modal Boolean @default(false) 79 | enable_image_logos Boolean @default(true) 80 | enable_carousel_view Boolean @default(false) 81 | force_compact_episode_view Boolean @default(false) 82 | source_order String[] @default([]) 83 | enable_source_order Boolean @default(false) 84 | disabled_sources String[] @default([]) 85 | embed_order String[] @default([]) 86 | enable_embed_order Boolean @default(false) 87 | disabled_embeds String[] @default([]) 88 | proxy_tmdb Boolean @default(false) 89 | enable_low_performance_mode Boolean @default(false) 90 | enable_native_subtitles Boolean @default(false) 91 | enable_hold_to_boost Boolean @default(false) 92 | home_section_order String[] @default([]) 93 | manual_source_selection Boolean @default(false) 94 | enable_double_click_to_seek Boolean @default(false) 95 | enable_auto_resume_on_playback_error Boolean @default(false) 96 | } 97 | 98 | model users { 99 | id String @id 100 | public_key String @unique(map: "users_public_key_unique") 101 | namespace String @db.VarChar(255) 102 | nickname String @db.VarChar(255) 103 | created_at DateTime @db.Timestamptz(0) 104 | last_logged_in DateTime? @db.Timestamptz(0) 105 | permissions String[] 106 | profile Json 107 | ratings Json @default("[]") 108 | } 109 | 110 | model lists { 111 | id String @id @default(uuid()) @db.Uuid 112 | user_id String @db.VarChar(255) 113 | name String @db.VarChar(255) 114 | description String? @db.VarChar(255) 115 | created_at DateTime @default(now()) @db.Timestamptz(0) 116 | updated_at DateTime @updatedAt @db.Timestamptz(0) 117 | public Boolean @default(false) 118 | list_items list_items[] 119 | 120 | @@index([user_id], map: "lists_user_id_index") 121 | } 122 | 123 | model list_items { 124 | id String @id @default(uuid()) @db.Uuid 125 | list_id String @db.Uuid 126 | tmdb_id String @db.VarChar(255) 127 | added_at DateTime @default(now()) @db.Timestamptz(0) 128 | type String? @db.VarChar(255) 129 | list lists @relation(fields: [list_id], references: [id]) 130 | 131 | @@unique([list_id, tmdb_id], map: "list_items_list_id_tmdb_id_unique") 132 | } 133 | 134 | model user_group_order { 135 | id String @id @default(uuid()) @db.Uuid 136 | user_id String @db.VarChar(255) 137 | group_order String[] @default([]) 138 | created_at DateTime @default(now()) @db.Timestamptz(0) 139 | updated_at DateTime @default(now()) @db.Timestamptz(0) 140 | 141 | @@unique([user_id], map: "user_group_order_user_id_unique") 142 | } 143 | 144 | model watch_history { 145 | id String @id @db.Uuid 146 | user_id String @db.VarChar(255) 147 | tmdb_id String @db.VarChar(255) 148 | season_id String? @db.VarChar(255) 149 | episode_id String? @db.VarChar(255) 150 | meta Json 151 | duration BigInt 152 | watched BigInt 153 | watched_at DateTime @db.Timestamptz(0) 154 | completed Boolean @default(false) 155 | season_number Int? 156 | episode_number Int? 157 | updated_at DateTime @default(now()) @updatedAt @db.Timestamptz(0) 158 | 159 | @@unique([tmdb_id, user_id, season_id, episode_id], map: "watch_history_tmdb_id_user_id_season_id_episode_id_unique") 160 | } 161 | -------------------------------------------------------------------------------- /server/routes/users/[id]/watch-history.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { randomUUID } from 'crypto'; 4 | 5 | const watchHistoryMetaSchema = z.object({ 6 | title: z.string(), 7 | year: z.number().optional(), 8 | poster: z.string().optional(), 9 | type: z.enum(['movie', 'show']), 10 | }); 11 | 12 | const watchHistoryItemSchema = z.object({ 13 | meta: watchHistoryMetaSchema, 14 | tmdbId: z.string(), 15 | duration: z.number().transform(n => n.toString()), 16 | watched: z.number().transform(n => n.toString()), 17 | watchedAt: z.string().datetime({ offset: true }), 18 | completed: z.boolean().optional().default(false), 19 | seasonId: z.string().optional(), 20 | episodeId: z.string().optional(), 21 | seasonNumber: z.number().optional(), 22 | episodeNumber: z.number().optional(), 23 | }); 24 | 25 | // 13th July 2021 - movie-web epoch 26 | const minEpoch = 1626134400000; 27 | 28 | function defaultAndCoerceDateTime(dateTime: string | undefined) { 29 | const epoch = dateTime ? new Date(dateTime).getTime() : Date.now(); 30 | const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now())); 31 | return new Date(clampedEpoch); 32 | } 33 | 34 | export default defineEventHandler(async event => { 35 | const userId = event.context.params?.id; 36 | const method = event.method; 37 | 38 | const session = await useAuth().getCurrentSession(); 39 | if (!session) { 40 | throw createError({ 41 | statusCode: 401, 42 | message: 'Session not found or expired', 43 | }); 44 | } 45 | 46 | if (session.user !== userId) { 47 | throw createError({ 48 | statusCode: 403, 49 | message: 'Cannot access other user information', 50 | }); 51 | } 52 | 53 | if (method === 'GET') { 54 | const items = await prisma.watch_history.findMany({ 55 | where: { user_id: userId }, 56 | orderBy: { watched_at: 'desc' }, 57 | }); 58 | 59 | return items.map(item => ({ 60 | id: item.id, 61 | tmdbId: item.tmdb_id, 62 | episode: { 63 | id: item.episode_id || null, 64 | number: item.episode_number || null, 65 | }, 66 | season: { 67 | id: item.season_id || null, 68 | number: item.season_number || null, 69 | }, 70 | meta: item.meta, 71 | duration: item.duration.toString(), 72 | watched: item.watched.toString(), 73 | watchedAt: item.watched_at.toISOString(), 74 | completed: item.completed, 75 | updatedAt: item.updated_at.toISOString(), 76 | })); 77 | } 78 | 79 | if (event.path.includes('/watch-history/')) { 80 | const segments = event.path.split('/'); 81 | const tmdbId = segments[segments.length - 1]; 82 | 83 | if (method === 'PUT') { 84 | const body = await readBody(event); 85 | const validatedBody = watchHistoryItemSchema.parse(body); 86 | 87 | const watchedAt = defaultAndCoerceDateTime(validatedBody.watchedAt); 88 | const now = new Date(); 89 | 90 | const existingItem = await prisma.watch_history.findUnique({ 91 | where: { 92 | tmdb_id_user_id_season_id_episode_id: { 93 | tmdb_id: tmdbId, 94 | user_id: userId, 95 | season_id: validatedBody.seasonId || null, 96 | episode_id: validatedBody.episodeId || null, 97 | }, 98 | }, 99 | }); 100 | 101 | let watchHistoryItem; 102 | 103 | if (existingItem) { 104 | watchHistoryItem = await prisma.watch_history.update({ 105 | where: { 106 | id: existingItem.id, 107 | }, 108 | data: { 109 | duration: BigInt(validatedBody.duration), 110 | watched: BigInt(validatedBody.watched), 111 | watched_at: watchedAt, 112 | completed: validatedBody.completed, 113 | meta: validatedBody.meta, 114 | updated_at: now, 115 | }, 116 | }); 117 | } else { 118 | watchHistoryItem = await prisma.watch_history.create({ 119 | data: { 120 | id: randomUUID(), 121 | tmdb_id: tmdbId, 122 | user_id: userId, 123 | season_id: validatedBody.seasonId || null, 124 | episode_id: validatedBody.episodeId || null, 125 | season_number: validatedBody.seasonNumber || null, 126 | episode_number: validatedBody.episodeNumber || null, 127 | duration: BigInt(validatedBody.duration), 128 | watched: BigInt(validatedBody.watched), 129 | watched_at: watchedAt, 130 | completed: validatedBody.completed, 131 | meta: validatedBody.meta, 132 | updated_at: now, 133 | }, 134 | }); 135 | } 136 | 137 | return { 138 | id: watchHistoryItem.id, 139 | tmdbId: watchHistoryItem.tmdb_id, 140 | userId: watchHistoryItem.user_id, 141 | seasonId: watchHistoryItem.season_id, 142 | episodeId: watchHistoryItem.episode_id, 143 | seasonNumber: watchHistoryItem.season_number, 144 | episodeNumber: watchHistoryItem.episode_number, 145 | meta: watchHistoryItem.meta, 146 | duration: Number(watchHistoryItem.duration), 147 | watched: Number(watchHistoryItem.watched), 148 | watchedAt: watchHistoryItem.watched_at.toISOString(), 149 | completed: watchHistoryItem.completed, 150 | updatedAt: watchHistoryItem.updated_at.toISOString(), 151 | }; 152 | } 153 | 154 | if (method === 'DELETE') { 155 | const body = await readBody(event).catch(() => ({})); 156 | 157 | const whereClause: any = { 158 | user_id: userId, 159 | tmdb_id: tmdbId, 160 | }; 161 | 162 | if (body.seasonId) whereClause.season_id = body.seasonId; 163 | if (body.episodeId) whereClause.episode_id = body.episodeId; 164 | 165 | const itemsToDelete = await prisma.watch_history.findMany({ 166 | where: whereClause, 167 | }); 168 | 169 | if (itemsToDelete.length === 0) { 170 | return { 171 | count: 0, 172 | tmdbId, 173 | episodeId: body.episodeId, 174 | seasonId: body.seasonId, 175 | }; 176 | } 177 | 178 | await prisma.watch_history.deleteMany({ 179 | where: whereClause, 180 | }); 181 | 182 | return { 183 | count: itemsToDelete.length, 184 | tmdbId, 185 | episodeId: body.episodeId, 186 | seasonId: body.seasonId, 187 | }; 188 | } 189 | } 190 | 191 | throw createError({ 192 | statusCode: 405, 193 | message: 'Method not allowed', 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /server/routes/users/[id]/progress/import.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { randomUUID } from 'crypto'; 4 | import { scopedLogger } from '~/utils/logger'; 5 | 6 | const log = scopedLogger('progress-import'); 7 | 8 | const progressMetaSchema = z.object({ 9 | title: z.string(), 10 | type: z.enum(['movie', 'show']), 11 | year: z.number().optional(), 12 | poster: z.string().optional(), 13 | }); 14 | 15 | const progressItemSchema = z.object({ 16 | meta: progressMetaSchema, 17 | tmdbId: z.string().transform(val => val || randomUUID()), 18 | duration: z 19 | .number() 20 | .min(0) 21 | .transform(n => Math.round(n)), 22 | watched: z 23 | .number() 24 | .min(0) 25 | .transform(n => Math.round(n)), 26 | seasonId: z.string().optional(), 27 | episodeId: z.string().optional(), 28 | seasonNumber: z.number().optional(), 29 | episodeNumber: z.number().optional(), 30 | updatedAt: z.string().datetime({ offset: true }).optional(), 31 | }); 32 | 33 | // 13th July 2021 - movie-web epoch 34 | const minEpoch = 1626134400000; 35 | 36 | function defaultAndCoerceDateTime(dateTime: string | undefined) { 37 | const epoch = dateTime ? new Date(dateTime).getTime() : Date.now(); 38 | const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now())); 39 | return new Date(clampedEpoch); 40 | } 41 | 42 | export default defineEventHandler(async event => { 43 | const userId = event.context.params?.id; 44 | 45 | const session = await useAuth().getCurrentSession(); 46 | 47 | if (session.user !== userId) { 48 | throw createError({ 49 | statusCode: 403, 50 | message: 'Cannot modify user other than yourself', 51 | }); 52 | } 53 | 54 | // First check if user exists 55 | const user = await prisma.users.findUnique({ 56 | where: { id: userId }, 57 | }); 58 | 59 | if (!user) { 60 | throw createError({ 61 | statusCode: 404, 62 | message: 'User not found', 63 | }); 64 | } 65 | 66 | if (event.method !== 'PUT') { 67 | throw createError({ 68 | statusCode: 405, 69 | message: 'Method not allowed', 70 | }); 71 | } 72 | 73 | try { 74 | const body = await readBody(event); 75 | const validatedBody = z.array(progressItemSchema).parse(body); 76 | 77 | const existingItems = await prisma.progress_items.findMany({ 78 | where: { user_id: userId }, 79 | }); 80 | 81 | const newItems = [...validatedBody]; 82 | const itemsToUpsert = []; 83 | 84 | for (const existingItem of existingItems) { 85 | const newItemIndex = newItems.findIndex( 86 | item => 87 | item.tmdbId === existingItem.tmdb_id && 88 | item.seasonId === (existingItem.season_id === '\n' ? null : existingItem.season_id) && 89 | item.episodeId === (existingItem.episode_id === '\n' ? null : existingItem.episode_id) 90 | ); 91 | 92 | if (newItemIndex > -1) { 93 | const newItem = newItems[newItemIndex]; 94 | 95 | if (Number(existingItem.watched) < newItem.watched) { 96 | const isMovie = newItem.meta.type === 'movie'; 97 | itemsToUpsert.push({ 98 | id: existingItem.id, 99 | tmdb_id: existingItem.tmdb_id, 100 | user_id: existingItem.user_id, 101 | season_id: isMovie ? '\n' : existingItem.season_id, 102 | episode_id: isMovie ? '\n' : existingItem.episode_id, 103 | season_number: existingItem.season_number, 104 | episode_number: existingItem.episode_number, 105 | duration: BigInt(newItem.duration), 106 | watched: BigInt(newItem.watched), 107 | meta: newItem.meta, 108 | updated_at: defaultAndCoerceDateTime(newItem.updatedAt), 109 | }); 110 | } 111 | 112 | newItems.splice(newItemIndex, 1); 113 | } 114 | } 115 | 116 | // Create new items 117 | for (const item of newItems) { 118 | const isMovie = item.meta.type === 'movie'; 119 | itemsToUpsert.push({ 120 | id: randomUUID(), 121 | tmdb_id: item.tmdbId, 122 | user_id: userId, 123 | season_id: isMovie ? '\n' : item.seasonId || null, 124 | episode_id: isMovie ? '\n' : item.episodeId || null, 125 | season_number: isMovie ? null : item.seasonNumber, 126 | episode_number: isMovie ? null : item.episodeNumber, 127 | duration: BigInt(item.duration), 128 | watched: BigInt(item.watched), 129 | meta: item.meta, 130 | updated_at: defaultAndCoerceDateTime(item.updatedAt), 131 | }); 132 | } 133 | 134 | // Upsert all items 135 | const results = []; 136 | for (const item of itemsToUpsert) { 137 | try { 138 | const result = await prisma.progress_items.upsert({ 139 | where: { 140 | tmdb_id_user_id_season_id_episode_id: { 141 | tmdb_id: item.tmdb_id, 142 | user_id: item.user_id, 143 | season_id: item.season_id, 144 | episode_id: item.episode_id, 145 | }, 146 | }, 147 | create: item, 148 | update: { 149 | duration: item.duration, 150 | watched: item.watched, 151 | meta: item.meta, 152 | updated_at: item.updated_at, 153 | }, 154 | }); 155 | 156 | results.push({ 157 | id: result.id, 158 | tmdbId: result.tmdb_id, 159 | episode: { 160 | id: result.episode_id === '\n' ? null : result.episode_id, 161 | number: result.episode_number, 162 | }, 163 | season: { 164 | id: result.season_id === '\n' ? null : result.season_id, 165 | number: result.season_number, 166 | }, 167 | meta: result.meta, 168 | duration: result.duration.toString(), 169 | watched: result.watched.toString(), 170 | updatedAt: result.updated_at.toISOString(), 171 | }); 172 | } catch (error) { 173 | log.error('Failed to upsert progress item', { 174 | userId, 175 | tmdbId: item.tmdb_id, 176 | error: error instanceof Error ? error.message : String(error), 177 | }); 178 | throw error; 179 | } 180 | } 181 | 182 | return results; 183 | } catch (error) { 184 | log.error('Failed to import progress', { 185 | userId, 186 | error: error instanceof Error ? error.message : String(error), 187 | }); 188 | 189 | if (error instanceof z.ZodError) { 190 | throw createError({ 191 | statusCode: 400, 192 | message: 'Invalid progress data', 193 | cause: error.errors, 194 | }); 195 | } 196 | 197 | throw createError({ 198 | statusCode: 500, 199 | message: 'Failed to import progress', 200 | cause: error instanceof Error ? error.message : 'Unknown error', 201 | }); 202 | } 203 | }); 204 | -------------------------------------------------------------------------------- /server/routes/users/[id]/progress.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { randomUUID } from 'crypto'; 4 | 5 | function progressIsNotStarted(duration: number, watched: number): boolean { 6 | // too short watch time 7 | if (watched < 20) return true; 8 | return false; 9 | } 10 | 11 | function progressIsCompleted(duration: number, watched: number): boolean { 12 | const timeFromEnd = duration - watched; 13 | // too close to the end, is completed 14 | if (timeFromEnd < 60 * 2) return true; 15 | return false; 16 | } 17 | 18 | async function shouldSaveProgress( 19 | userId: string, 20 | tmdbId: string, 21 | validatedBody: any, 22 | prisma: any 23 | ): Promise { 24 | const duration = parseInt(validatedBody.duration); 25 | const watched = parseInt(validatedBody.watched); 26 | 27 | // Check if progress is acceptable 28 | const isNotStarted = progressIsNotStarted(duration, watched); 29 | const isCompleted = progressIsCompleted(duration, watched); 30 | const isAcceptable = !isNotStarted && !isCompleted; 31 | 32 | // For movies, only save if acceptable 33 | if (validatedBody.meta.type === 'movie') { 34 | return isAcceptable; 35 | } 36 | 37 | // For shows, save if acceptable OR if season has other watched episodes 38 | if (isAcceptable) return true; 39 | 40 | // Check if this season has other episodes with progress 41 | if (!validatedBody.seasonId) return false; 42 | 43 | const seasonEpisodes = await prisma.progress_items.findMany({ 44 | where: { 45 | user_id: userId, 46 | tmdb_id: tmdbId, 47 | season_id: validatedBody.seasonId, 48 | episode_id: { 49 | not: validatedBody.episodeId || null 50 | } 51 | } 52 | }); 53 | 54 | // Check if any other episode in this season has acceptable progress 55 | return seasonEpisodes.some((episode: any) => { 56 | const epDuration = Number(episode.duration); 57 | const epWatched = Number(episode.watched); 58 | return !progressIsNotStarted(epDuration, epWatched) && 59 | !progressIsCompleted(epDuration, epWatched); 60 | }); 61 | } 62 | 63 | const progressMetaSchema = z.object({ 64 | title: z.string(), 65 | year: z.number().optional(), 66 | poster: z.string().optional(), 67 | type: z.enum(['movie', 'show']), 68 | }); 69 | 70 | const progressItemSchema = z.object({ 71 | meta: progressMetaSchema, 72 | tmdbId: z.string(), 73 | duration: z.number().transform(n => n.toString()), 74 | watched: z.number().transform(n => n.toString()), 75 | seasonId: z.string().optional(), 76 | episodeId: z.string().optional(), 77 | seasonNumber: z.number().optional(), 78 | episodeNumber: z.number().optional(), 79 | updatedAt: z.string().datetime({ offset: true }).optional(), 80 | }); 81 | 82 | // 13th July 2021 - movie-web epoch 83 | const minEpoch = 1626134400000; 84 | 85 | function defaultAndCoerceDateTime(dateTime: string | undefined) { 86 | const epoch = dateTime ? new Date(dateTime).getTime() : Date.now(); 87 | const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now())); 88 | return new Date(clampedEpoch); 89 | } 90 | 91 | export default defineEventHandler(async event => { 92 | const userId = event.context.params?.id; 93 | const method = event.method; 94 | 95 | const session = await useAuth().getCurrentSession(); 96 | if (!session) { 97 | throw createError({ 98 | statusCode: 401, 99 | message: 'Session not found or expired', 100 | }); 101 | } 102 | 103 | if (session.user !== userId) { 104 | throw createError({ 105 | statusCode: 403, 106 | message: 'Cannot access other user information', 107 | }); 108 | } 109 | 110 | if (method === 'GET') { 111 | const items = await prisma.progress_items.findMany({ 112 | where: { user_id: userId }, 113 | }); 114 | 115 | return items.map(item => ({ 116 | id: item.id, 117 | tmdbId: item.tmdb_id, 118 | episode: { 119 | id: item.episode_id || null, 120 | number: item.episode_number || null, 121 | }, 122 | season: { 123 | id: item.season_id || null, 124 | number: item.season_number || null, 125 | }, 126 | meta: item.meta, 127 | duration: item.duration.toString(), 128 | watched: item.watched.toString(), 129 | updatedAt: item.updated_at.toISOString(), 130 | })); 131 | } 132 | 133 | if (method === 'DELETE' && event.path.endsWith('/progress/cleanup')) { 134 | // Clean up unwanted progress items (unwatched or finished) 135 | const allItems = await prisma.progress_items.findMany({ 136 | where: { user_id: userId }, 137 | }); 138 | 139 | const itemsToDelete: string[] = []; 140 | 141 | // Group items by tmdbId for show processing 142 | const itemsByTmdbId: Record = {}; 143 | for (const item of allItems) { 144 | if (!itemsByTmdbId[item.tmdb_id]) { 145 | itemsByTmdbId[item.tmdb_id] = []; 146 | } 147 | itemsByTmdbId[item.tmdb_id].push(item); 148 | } 149 | 150 | for (const [tmdbId, items] of Object.entries(itemsByTmdbId)) { 151 | const movieItems = items.filter(item => !item.episode_id); 152 | const episodeItems = items.filter(item => item.episode_id); 153 | 154 | // Process movies 155 | for (const item of movieItems) { 156 | const duration = Number(item.duration); 157 | const watched = Number(item.watched); 158 | const isNotStarted = progressIsNotStarted(duration, watched); 159 | const isCompleted = progressIsCompleted(duration, watched); 160 | 161 | if (isNotStarted || isCompleted) { 162 | itemsToDelete.push(item.id); 163 | } 164 | } 165 | 166 | // Process episodes - group by season 167 | const episodesBySeason: Record = {}; 168 | for (const item of episodeItems) { 169 | const seasonKey = `${item.season_id}`; 170 | if (!episodesBySeason[seasonKey]) { 171 | episodesBySeason[seasonKey] = []; 172 | } 173 | episodesBySeason[seasonKey].push(item); 174 | } 175 | 176 | for (const seasonItems of Object.values(episodesBySeason)) { 177 | // Check if season has any acceptable episodes 178 | const hasAcceptableEpisodes = seasonItems.some((item: any) => { 179 | const duration = Number(item.duration); 180 | const watched = Number(item.watched); 181 | return !progressIsNotStarted(duration, watched) && 182 | !progressIsCompleted(duration, watched); 183 | }); 184 | 185 | if (hasAcceptableEpisodes) { 186 | // If season has acceptable episodes, only delete unacceptable ones 187 | for (const item of seasonItems) { 188 | const duration = Number(item.duration); 189 | const watched = Number(item.watched); 190 | const isNotStarted = progressIsNotStarted(duration, watched); 191 | const isCompleted = progressIsCompleted(duration, watched); 192 | 193 | if (isNotStarted || isCompleted) { 194 | itemsToDelete.push(item.id); 195 | } 196 | } 197 | } else { 198 | // If no acceptable episodes in season, delete all 199 | itemsToDelete.push(...seasonItems.map((item: any) => item.id)); 200 | } 201 | } 202 | } 203 | 204 | if (itemsToDelete.length > 0) { 205 | await prisma.progress_items.deleteMany({ 206 | where: { 207 | id: { in: itemsToDelete }, 208 | user_id: userId, 209 | }, 210 | }); 211 | } 212 | 213 | return { 214 | deletedCount: itemsToDelete.length, 215 | message: `Cleaned up ${itemsToDelete.length} unwanted progress items`, 216 | }; 217 | } 218 | 219 | if (event.path.includes('/progress/') && !event.path.endsWith('/import') && !event.path.endsWith('/cleanup')) { 220 | const segments = event.path.split('/'); 221 | const tmdbId = segments[segments.length - 1]; 222 | 223 | if (method === 'PUT') { 224 | const body = await readBody(event); 225 | const validatedBody = progressItemSchema.parse(body); 226 | 227 | // Check if this progress should be saved 228 | const shouldSave = await shouldSaveProgress(userId, tmdbId, validatedBody, prisma); 229 | if (!shouldSave) { 230 | // Return early without saving 231 | return { 232 | id: '', 233 | tmdbId, 234 | userId, 235 | seasonId: validatedBody.seasonId, 236 | episodeId: validatedBody.episodeId, 237 | seasonNumber: validatedBody.seasonNumber, 238 | episodeNumber: validatedBody.episodeNumber, 239 | meta: validatedBody.meta, 240 | duration: parseInt(validatedBody.duration), 241 | watched: parseInt(validatedBody.watched), 242 | updatedAt: defaultAndCoerceDateTime(validatedBody.updatedAt), 243 | }; 244 | } 245 | 246 | const now = defaultAndCoerceDateTime(validatedBody.updatedAt); 247 | 248 | const existingItem = await prisma.progress_items.findUnique({ 249 | where: { 250 | tmdb_id_user_id_season_id_episode_id: { 251 | tmdb_id: tmdbId, 252 | user_id: userId, 253 | season_id: validatedBody.seasonId || null, 254 | episode_id: validatedBody.episodeId || null, 255 | }, 256 | }, 257 | }); 258 | 259 | let progressItem; 260 | 261 | if (existingItem) { 262 | progressItem = await prisma.progress_items.update({ 263 | where: { 264 | id: existingItem.id, 265 | }, 266 | data: { 267 | duration: BigInt(validatedBody.duration), 268 | watched: BigInt(validatedBody.watched), 269 | meta: validatedBody.meta, 270 | updated_at: now, 271 | }, 272 | }); 273 | } else { 274 | progressItem = await prisma.progress_items.create({ 275 | data: { 276 | id: randomUUID(), 277 | tmdb_id: tmdbId, 278 | user_id: userId, 279 | season_id: validatedBody.seasonId || null, 280 | episode_id: validatedBody.episodeId || null, 281 | season_number: validatedBody.seasonNumber || null, 282 | episode_number: validatedBody.episodeNumber || null, 283 | duration: BigInt(validatedBody.duration), 284 | watched: BigInt(validatedBody.watched), 285 | meta: validatedBody.meta, 286 | updated_at: now, 287 | }, 288 | }); 289 | } 290 | 291 | return { 292 | id: progressItem.id, 293 | tmdbId: progressItem.tmdb_id, 294 | userId: progressItem.user_id, 295 | seasonId: progressItem.season_id, 296 | episodeId: progressItem.episode_id, 297 | seasonNumber: progressItem.season_number, 298 | episodeNumber: progressItem.episode_number, 299 | meta: progressItem.meta, 300 | duration: Number(progressItem.duration), 301 | watched: Number(progressItem.watched), 302 | updatedAt: progressItem.updated_at, 303 | }; 304 | } 305 | 306 | if (method === 'DELETE') { 307 | const body = await readBody(event).catch(() => ({})); 308 | 309 | const whereClause: any = { 310 | user_id: userId, 311 | tmdb_id: tmdbId, 312 | }; 313 | 314 | if (body.seasonId) whereClause.season_id = body.seasonId; 315 | if (body.episodeId) whereClause.episode_id = body.episodeId; 316 | 317 | const itemsToDelete = await prisma.progress_items.findMany({ 318 | where: whereClause, 319 | }); 320 | 321 | if (itemsToDelete.length === 0) { 322 | return { 323 | count: 0, 324 | tmdbId, 325 | episodeId: body.episodeId, 326 | seasonId: body.seasonId, 327 | }; 328 | } 329 | 330 | await prisma.progress_items.deleteMany({ 331 | where: whereClause, 332 | }); 333 | 334 | return { 335 | count: itemsToDelete.length, 336 | tmdbId, 337 | episodeId: body.episodeId, 338 | seasonId: body.seasonId, 339 | }; 340 | } 341 | } 342 | 343 | throw createError({ 344 | statusCode: 405, 345 | message: 'Method not allowed', 346 | }); 347 | }); 348 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-testing.yaml: -------------------------------------------------------------------------------- 1 | name: PR Test - Build and Integration Test 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | env: 8 | NODE_VERSION: '22' 9 | 10 | jobs: 11 | build-and-test: 12 | runs-on: ubuntu-latest 13 | 14 | services: 15 | postgres: 16 | image: postgres:16-alpine 17 | env: 18 | POSTGRES_USER: testuser 19 | POSTGRES_PASSWORD: testpassword 20 | POSTGRES_DB: testdb 21 | ports: 22 | - 5432:5432 23 | options: >- 24 | --health-cmd="pg_isready -U testuser -d testdb" 25 | --health-interval=10s 26 | --health-timeout=5s 27 | --health-retries=5 28 | 29 | steps: 30 | - name: Checkout PR 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: ${{ env.NODE_VERSION }} 37 | cache: 'npm' 38 | 39 | - name: Install dependencies 40 | run: npm install 41 | 42 | - name: Generate Prisma client 43 | env: 44 | DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb 45 | run: npx prisma generate 46 | 47 | - name: Run database migrations 48 | env: 49 | DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb 50 | run: npx prisma migrate deploy 51 | 52 | - name: Build the backend 53 | env: 54 | DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb 55 | META_NAME: 'CI Backend Test server' 56 | META_DESCRIPTION: 'CI Test Instance' 57 | CRYPTO_SECRET: '0GwvbW7ZHlpZ6y0uSkU22Xi7XjoMpHX' # This is just for the CI server. DO NOT USE IN PROD 58 | run: npm run build 59 | 60 | - name: Start the backend server 61 | env: 62 | DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb 63 | META_NAME: 'Test Backend' 64 | META_DESCRIPTION: 'CI Test Instance' 65 | CRYPTO_SECRET: '0GwvbW7ZHlpZ6y0uSkU22Xi7XjoMpHX' 66 | run: | 67 | node .output/server/index.mjs & 68 | echo $! > server.pid 69 | # Wait for server to be ready 70 | for i in {1..30}; do 71 | if curl -s http://localhost:3000 > /dev/null; then 72 | echo "Server is ready!" 73 | break 74 | fi 75 | echo "Waiting for server... attempt $i" 76 | sleep 2 77 | done 78 | 79 | - name: Verify server is running 80 | run: | 81 | response=$(curl -s http://localhost:3000) 82 | echo "Server response: $response" 83 | if echo "$response" | grep -q "Backend is working"; then 84 | echo "+------------------+------+" 85 | echo "| Backend Working? | True |" 86 | echo "+------------------+------+" 87 | 88 | else 89 | echo "+------------------+--------+" 90 | echo "| Backend Working? | FAILED |" 91 | echo "+------------------+--------+" 92 | exit 1 93 | fi 94 | 95 | - name: Run account creation integration test 96 | run: | 97 | # Create a Node.js script to handle the crypto operations 98 | cat > /tmp/auth-test.mjs << 'EOF' 99 | import crypto from 'crypto'; 100 | import nacl from 'tweetnacl'; 101 | 102 | const TEST_MNEMONIC = "awkward safe differ large subway junk gallery flight left glue fault glory"; 103 | 104 | function toBase64Url(buffer) { 105 | return Buffer.from(buffer) 106 | .toString('base64') 107 | .replace(/\+/g, '-') 108 | .replace(/\//g, '_') 109 | .replace(/=+$/g, ''); 110 | } 111 | 112 | async function pbkdf2Async(password, salt, iterations, keyLen, digest) { 113 | return new Promise((resolve, reject) => { 114 | crypto.pbkdf2(password, salt, iterations, keyLen, digest, (err, derivedKey) => { 115 | if (err) return reject(err); 116 | resolve(new Uint8Array(derivedKey)); 117 | }); 118 | }); 119 | } 120 | 121 | async function main() { 122 | console.log("=== Step 1: Getting challenge code ==="); 123 | const challengeRes = await fetch('http://localhost:3000/auth/register/start', { 124 | method: 'POST', 125 | headers: { 'Content-Type': 'application/json' }, 126 | body: JSON.stringify({}) 127 | }); 128 | const challengeData = await challengeRes.json(); 129 | const challengeCode = challengeData.challenge; 130 | 131 | if (!challengeCode) { 132 | console.log("+----------------+-----------+"); 133 | console.log("| Challenge Code | NOT FOUND |"); 134 | console.log("+----------------+-----------+"); 135 | process.exit(1); 136 | } 137 | console.log("+----------------+-------+"); 138 | console.log("| Challenge Code | FOUND |"); 139 | console.log("+----------------+-------+"); 140 | console.log(`Challenge: ${challengeCode}`); 141 | 142 | console.log("\n=== Step 2: Deriving keypair from mnemonic ==="); 143 | const seed = await pbkdf2Async(TEST_MNEMONIC, 'mnemonic', 2048, 32, 'sha256'); 144 | const keyPair = nacl.sign.keyPair.fromSeed(seed); 145 | const publicKeyBase64Url = toBase64Url(keyPair.publicKey); 146 | console.log(`Public Key: ${publicKeyBase64Url}`); 147 | console.log("+------------+---------+"); 148 | console.log("| Public Key | DERIVED |"); 149 | console.log("+------------+---------+"); 150 | 151 | const messageBuffer = Buffer.from(challengeCode); 152 | const signature = nacl.sign.detached(messageBuffer, keyPair.secretKey); 153 | const signatureBase64Url = toBase64Url(signature); 154 | console.log(`Signature: ${signatureBase64Url}`); 155 | console.log("+------------------+--------+"); 156 | console.log("| Challenge Signed | SUCCESS |"); 157 | console.log("+------------------+--------+"); 158 | 159 | const registerRes = await fetch('http://localhost:3000/auth/register/complete', { 160 | method: 'POST', 161 | headers: { 162 | 'Content-Type': 'application/json', 163 | 'User-Agent': 'CI-Test-Agent/1.0' 164 | }, 165 | body: JSON.stringify({ 166 | publicKey: publicKeyBase64Url, 167 | challenge: { 168 | code: challengeCode, 169 | signature: signatureBase64Url 170 | }, 171 | namespace: "ci-test", 172 | device: "CI-Test-Device", 173 | profile: { 174 | colorA: "#FF0000", 175 | colorB: "#00FF00", 176 | icon: "user" 177 | } 178 | }) 179 | }); 180 | 181 | const registerData = await registerRes.json(); 182 | 183 | if (registerData.user && registerData.token) { 184 | console.log("+---------------+---------+"); 185 | console.log("| Registration | SUCCESS |"); 186 | console.log("+---------------+---------+"); 187 | console.log(`User ID: ${registerData.user.id}`); 188 | console.log(`Nickname: ${registerData.user.nickname}`); 189 | console.log(`Token: ${registerData.token.substring(0, 50)}...`); 190 | 191 | const meRes = await fetch('http://localhost:3000/users/@me', { 192 | headers: { 193 | 'Authorization': `Bearer ${registerData.token}` 194 | } 195 | }); 196 | const meData = await meRes.json(); 197 | 198 | if (meData.user && meData.user.id === registerData.user.id) { 199 | console.log("+------------------+---------+"); 200 | console.log("| Auth Validation | SUCCESS |"); 201 | console.log("+------------------+---------+"); 202 | } else { 203 | console.log("+------------------+--------+"); 204 | console.log("| Auth Validation | FAILED |"); 205 | console.log("+------------------+--------+"); 206 | process.exit(1); 207 | } 208 | 209 | const loginStartRes = await fetch('http://localhost:3000/auth/login/start', { 210 | method: 'POST', 211 | headers: { 'Content-Type': 'application/json' }, 212 | body: JSON.stringify({ publicKey: publicKeyBase64Url }) 213 | }); 214 | const loginStartData = await loginStartRes.json(); 215 | 216 | if (loginStartData.challenge) { 217 | const loginChallenge = loginStartData.challenge; 218 | const loginSignature = nacl.sign.detached(Buffer.from(loginChallenge), keyPair.secretKey); 219 | const loginSignatureBase64Url = toBase64Url(loginSignature); 220 | 221 | const loginCompleteRes = await fetch('http://localhost:3000/auth/login/complete', { 222 | method: 'POST', 223 | headers: { 224 | 'Content-Type': 'application/json', 225 | 'User-Agent': 'CI-Test-Agent/1.0' 226 | }, 227 | body: JSON.stringify({ 228 | publicKey: publicKeyBase64Url, 229 | challenge: { 230 | code: loginChallenge, 231 | signature: loginSignatureBase64Url 232 | }, 233 | device: "CI-Test-Device-Login" 234 | }) 235 | }); 236 | 237 | const loginData = await loginCompleteRes.json(); 238 | 239 | if (loginData.user && loginData.token) { 240 | console.log("+------------+---------+"); 241 | console.log("| Login Flow | SUCCESS |"); 242 | console.log("+------------+---------+"); 243 | } else { 244 | console.log("+------------+--------+"); 245 | console.log("| Login Flow | FAILED |"); 246 | console.log("+------------+--------+"); 247 | console.log(JSON.stringify(loginData, null, 2)); 248 | process.exit(1); 249 | } 250 | } 251 | 252 | } else { 253 | console.log("+---------------+--------+"); 254 | console.log("| Registration | FAILED |"); 255 | console.log("+---------------+--------+"); 256 | console.log(JSON.stringify(registerData, null, 2)); 257 | process.exit(1); 258 | } 259 | 260 | console.log("\n+---------------------------+---------+"); 261 | console.log("| All Auth Tests Completed | SUCCESS |"); 262 | console.log("+---------------------------+---------+"); 263 | } 264 | 265 | main().catch(err => { 266 | console.error("Test failed:", err); 267 | process.exit(1); 268 | }); 269 | EOF 270 | 271 | node /tmp/auth-test.mjs 272 | 273 | - name: Run API endpoint tests 274 | run: | 275 | echo "=== Testing /meta endpoint ===" 276 | META_RESPONSE=$(curl -s http://localhost:3000/meta) 277 | echo "Meta response: $META_RESPONSE" 278 | 279 | if echo "$META_RESPONSE" | grep -q "name"; then 280 | echo "+---------------+---------+" 281 | echo "| Meta Endpoint | SUCCESS |" 282 | echo "+---------------+---------+" 283 | else 284 | echo "+---------------+--------+" 285 | echo "| Meta Endpoint | FAILED |" 286 | echo "+---------------+--------+" 287 | exit 1 288 | fi 289 | 290 | echo "" 291 | echo "+-----------------------------+---------+" 292 | echo "| All Integration Tests | PASSED |" 293 | echo "+-----------------------------+---------+" 294 | 295 | - name: Stop the server 296 | if: always() 297 | run: | 298 | if [ -f server.pid ]; then 299 | kill $(cat server.pid) 2>/dev/null || true 300 | fi 301 | 302 | - name: Upload build artifacts 303 | if: failure() 304 | uses: actions/upload-artifact@v4 305 | with: 306 | name: build-output 307 | path: .output/ 308 | retention-days: 5 309 | -------------------------------------------------------------------------------- /server/routes/users/[id]/settings.ts: -------------------------------------------------------------------------------- 1 | import { useAuth } from '~/utils/auth'; 2 | import { z } from 'zod'; 3 | import { scopedLogger } from '~/utils/logger'; 4 | import type { user_settings } from '~/../generated/client'; 5 | import { prisma } from '~/utils/prisma'; 6 | 7 | const log = scopedLogger('user-settings'); 8 | 9 | const userSettingsSchema = z.object({ 10 | applicationTheme: z.string().nullable().optional(), 11 | applicationLanguage: z.string().optional().default('en'), 12 | defaultSubtitleLanguage: z.string().nullable().optional(), 13 | proxyUrls: z.array(z.string()).nullable().optional(), 14 | traktKey: z.string().nullable().optional(), 15 | febboxKey: z.string().nullable().optional(), 16 | debridToken: z.string().nullable().optional(), 17 | debridService: z.string().nullable().optional(), 18 | enableThumbnails: z.boolean().optional().default(false), 19 | enableAutoplay: z.boolean().optional().default(true), 20 | enableSkipCredits: z.boolean().optional().default(true), 21 | enableDiscover: z.boolean().optional().default(true), 22 | enableFeatured: z.boolean().optional().default(false), 23 | enableDetailsModal: z.boolean().optional().default(false), 24 | enableImageLogos: z.boolean().optional().default(true), 25 | enableCarouselView: z.boolean().optional().default(false), 26 | forceCompactEpisodeView: z.boolean().optional().default(false), 27 | sourceOrder: z.array(z.string()).optional().default([]), 28 | enableSourceOrder: z.boolean().optional().default(false), 29 | disabledSources: z.array(z.string()).optional().default([]), 30 | embedOrder: z.array(z.string()).optional().default([]), 31 | enableEmbedOrder: z.boolean().optional().default(false), 32 | disabledEmbeds: z.array(z.string()).optional().default([]), 33 | proxyTmdb: z.boolean().optional().default(false), 34 | enableLowPerformanceMode: z.boolean().optional().default(false), 35 | enableNativeSubtitles: z.boolean().optional().default(false), 36 | enableHoldToBoost: z.boolean().optional().default(false), 37 | homeSectionOrder: z.array(z.string()).optional().default([]), 38 | manualSourceSelection: z.boolean().optional().default(false), 39 | enableDoubleClickToSeek: z.boolean().optional().default(false), 40 | enableAutoResumeOnPlaybackError: z.boolean().optional().default(false), 41 | }); 42 | 43 | export default defineEventHandler(async event => { 44 | const userId = event.context.params?.id; 45 | 46 | const session = await useAuth().getCurrentSession(); 47 | 48 | if (session.user !== userId) { 49 | throw createError({ 50 | statusCode: 403, 51 | message: 'Permission denied', 52 | }); 53 | } 54 | 55 | // First check if user exists 56 | const user = await prisma.users.findUnique({ 57 | where: { id: userId }, 58 | }); 59 | 60 | if (!user) { 61 | throw createError({ 62 | statusCode: 404, 63 | message: 'User not found', 64 | }); 65 | } 66 | 67 | if (event.method === 'GET') { 68 | try { 69 | const settings = (await prisma.user_settings.findUnique({ 70 | where: { id: userId }, 71 | })) as unknown as user_settings | null; 72 | 73 | return { 74 | id: userId, 75 | applicationTheme: settings?.application_theme || null, 76 | applicationLanguage: settings?.application_language || 'en', 77 | defaultSubtitleLanguage: settings?.default_subtitle_language || null, 78 | proxyUrls: settings?.proxy_urls.length === 0 ? null : settings?.proxy_urls || null, 79 | traktKey: settings?.trakt_key || null, 80 | febboxKey: settings?.febbox_key || null, 81 | debridToken: settings?.debrid_token || null, 82 | debridService: settings?.debrid_service || null, 83 | enableThumbnails: settings?.enable_thumbnails ?? false, 84 | enableAutoplay: settings?.enable_autoplay ?? true, 85 | enableSkipCredits: settings?.enable_skip_credits ?? true, 86 | enableDiscover: settings?.enable_discover ?? true, 87 | enableFeatured: settings?.enable_featured ?? false, 88 | enableDetailsModal: settings?.enable_details_modal ?? false, 89 | enableImageLogos: settings?.enable_image_logos ?? true, 90 | enableCarouselView: settings?.enable_carousel_view ?? false, 91 | forceCompactEpisodeView: settings?.force_compact_episode_view ?? false, 92 | sourceOrder: settings?.source_order || [], 93 | enableSourceOrder: settings?.enable_source_order ?? false, 94 | disabledSources: settings?.disabled_sources || [], 95 | embedOrder: settings?.embed_order || [], 96 | enableEmbedOrder: settings?.enable_embed_order ?? false, 97 | disabledEmbeds: settings?.disabled_embeds || [], 98 | proxyTmdb: settings?.proxy_tmdb ?? false, 99 | enableLowPerformanceMode: settings?.enable_low_performance_mode ?? false, 100 | enableNativeSubtitles: settings?.enable_native_subtitles ?? false, 101 | enableHoldToBoost: settings?.enable_hold_to_boost ?? false, 102 | homeSectionOrder: settings?.home_section_order || [], 103 | manualSourceSelection: settings?.manual_source_selection ?? false, 104 | enableDoubleClickToSeek: settings?.enable_double_click_to_seek ?? false, 105 | }; 106 | } catch (error) { 107 | log.error('Failed to get user settings', { 108 | userId, 109 | error: error instanceof Error ? error.message : String(error), 110 | }); 111 | throw createError({ 112 | statusCode: 500, 113 | message: 'Failed to get user settings', 114 | }); 115 | } 116 | } 117 | 118 | if (event.method === 'PUT') { 119 | try { 120 | const body = await readBody(event); 121 | log.info('Updating user settings', { userId, body }); 122 | 123 | const validatedBody = userSettingsSchema.parse(body); 124 | 125 | const createData = { 126 | application_theme: validatedBody.applicationTheme ?? null, 127 | application_language: validatedBody.applicationLanguage, 128 | default_subtitle_language: validatedBody.defaultSubtitleLanguage ?? null, 129 | proxy_urls: validatedBody.proxyUrls === null ? [] : validatedBody.proxyUrls || [], 130 | trakt_key: validatedBody.traktKey ?? null, 131 | febbox_key: validatedBody.febboxKey ?? null, 132 | debrid_token: validatedBody.debridToken ?? null, 133 | debrid_service: validatedBody.debridService ?? null, 134 | enable_thumbnails: validatedBody.enableThumbnails, 135 | enable_autoplay: validatedBody.enableAutoplay, 136 | enable_skip_credits: validatedBody.enableSkipCredits, 137 | enable_discover: validatedBody.enableDiscover, 138 | enable_featured: validatedBody.enableFeatured, 139 | enable_details_modal: validatedBody.enableDetailsModal, 140 | enable_image_logos: validatedBody.enableImageLogos, 141 | enable_carousel_view: validatedBody.enableCarouselView, 142 | force_compact_episode_view: validatedBody.forceCompactEpisodeView, 143 | source_order: validatedBody.sourceOrder || [], 144 | enable_source_order: validatedBody.enableSourceOrder, 145 | disabled_sources: validatedBody.disabledSources || [], 146 | embed_order: validatedBody.embedOrder || [], 147 | enable_embed_order: validatedBody.enableEmbedOrder, 148 | disabled_embeds: validatedBody.disabledEmbeds || [], 149 | proxy_tmdb: validatedBody.proxyTmdb, 150 | enable_low_performance_mode: validatedBody.enableLowPerformanceMode, 151 | enable_native_subtitles: validatedBody.enableNativeSubtitles, 152 | enable_hold_to_boost: validatedBody.enableHoldToBoost, 153 | home_section_order: validatedBody.homeSectionOrder || [], 154 | manual_source_selection: validatedBody.manualSourceSelection, 155 | enable_double_click_to_seek: validatedBody.enableDoubleClickToSeek, 156 | enable_auto_resume_on_playback_error: false, 157 | }; 158 | 159 | const updateData: Partial = {}; 160 | if (Object.prototype.hasOwnProperty.call(body, 'applicationTheme')) 161 | updateData.application_theme = createData.application_theme; 162 | if (Object.prototype.hasOwnProperty.call(body, 'applicationLanguage')) 163 | updateData.application_language = createData.application_language; 164 | if (Object.prototype.hasOwnProperty.call(body, 'defaultSubtitleLanguage')) 165 | updateData.default_subtitle_language = createData.default_subtitle_language; 166 | if (Object.prototype.hasOwnProperty.call(body, 'proxyUrls')) 167 | updateData.proxy_urls = createData.proxy_urls; 168 | if (Object.prototype.hasOwnProperty.call(body, 'traktKey')) 169 | updateData.trakt_key = createData.trakt_key; 170 | if (Object.prototype.hasOwnProperty.call(body, 'febboxKey')) 171 | updateData.febbox_key = createData.febbox_key; 172 | if (Object.prototype.hasOwnProperty.call(body, 'debridToken')) 173 | updateData.debrid_token = createData.debrid_token; 174 | if (Object.prototype.hasOwnProperty.call(body, 'debridService')) 175 | updateData.debrid_service = createData.debrid_service; 176 | if (Object.prototype.hasOwnProperty.call(body, 'enableThumbnails')) 177 | updateData.enable_thumbnails = createData.enable_thumbnails; 178 | if (Object.prototype.hasOwnProperty.call(body, 'enableAutoplay')) 179 | updateData.enable_autoplay = createData.enable_autoplay; 180 | if (Object.prototype.hasOwnProperty.call(body, 'enableSkipCredits')) 181 | updateData.enable_skip_credits = createData.enable_skip_credits; 182 | if (Object.prototype.hasOwnProperty.call(body, 'enableDiscover')) 183 | updateData.enable_discover = createData.enable_discover; 184 | if (Object.prototype.hasOwnProperty.call(body, 'enableFeatured')) 185 | updateData.enable_featured = createData.enable_featured; 186 | if (Object.prototype.hasOwnProperty.call(body, 'enableDetailsModal')) 187 | updateData.enable_details_modal = createData.enable_details_modal; 188 | if (Object.prototype.hasOwnProperty.call(body, 'enableImageLogos')) 189 | updateData.enable_image_logos = createData.enable_image_logos; 190 | if (Object.prototype.hasOwnProperty.call(body, 'enableCarouselView')) 191 | updateData.enable_carousel_view = createData.enable_carousel_view; 192 | if (Object.prototype.hasOwnProperty.call(body, 'forceCompactEpisodeView')) 193 | updateData.force_compact_episode_view = createData.force_compact_episode_view; 194 | if (Object.prototype.hasOwnProperty.call(body, 'sourceOrder')) 195 | updateData.source_order = createData.source_order; 196 | if (Object.prototype.hasOwnProperty.call(body, 'enableSourceOrder')) 197 | updateData.enable_source_order = createData.enable_source_order; 198 | if (Object.prototype.hasOwnProperty.call(body, 'disabledSources')) 199 | updateData.disabled_sources = createData.disabled_sources; 200 | if (Object.prototype.hasOwnProperty.call(body, 'embedOrder')) 201 | updateData.embed_order = createData.embed_order; 202 | if (Object.prototype.hasOwnProperty.call(body, 'enableEmbedOrder')) 203 | updateData.enable_embed_order = createData.enable_embed_order; 204 | if (Object.prototype.hasOwnProperty.call(body, 'disabledEmbeds')) 205 | updateData.disabled_embeds = createData.disabled_embeds; 206 | if (Object.prototype.hasOwnProperty.call(body, 'proxyTmdb')) 207 | updateData.proxy_tmdb = createData.proxy_tmdb; 208 | if (Object.prototype.hasOwnProperty.call(body, 'enableLowPerformanceMode')) 209 | updateData.enable_low_performance_mode = createData.enable_low_performance_mode; 210 | if (Object.prototype.hasOwnProperty.call(body, 'enableNativeSubtitles')) 211 | updateData.enable_native_subtitles = createData.enable_native_subtitles; 212 | if (Object.prototype.hasOwnProperty.call(body, 'enableHoldToBoost')) 213 | updateData.enable_hold_to_boost = createData.enable_hold_to_boost; 214 | if (Object.prototype.hasOwnProperty.call(body, 'homeSectionOrder')) 215 | updateData.home_section_order = createData.home_section_order; 216 | if (Object.prototype.hasOwnProperty.call(body, 'manualSourceSelection')) 217 | updateData.manual_source_selection = createData.manual_source_selection; 218 | if (Object.prototype.hasOwnProperty.call(body, 'enableDoubleClickToSeek')) 219 | updateData.enable_double_click_to_seek = createData.enable_double_click_to_seek; 220 | 221 | log.info('Preparing to upsert settings', { 222 | userId, 223 | updateData, 224 | createData: { id: userId, ...createData }, 225 | }); 226 | 227 | const settings = (await prisma.user_settings.upsert({ 228 | where: { id: userId }, 229 | update: updateData, 230 | create: { 231 | id: userId, 232 | ...createData, 233 | }, 234 | })) as unknown as user_settings; 235 | 236 | log.info('Settings updated successfully', { userId }); 237 | 238 | return { 239 | id: userId, 240 | applicationTheme: settings.application_theme, 241 | applicationLanguage: settings.application_language, 242 | defaultSubtitleLanguage: settings.default_subtitle_language, 243 | proxyUrls: settings.proxy_urls.length === 0 ? null : settings.proxy_urls, 244 | traktKey: settings.trakt_key, 245 | febboxKey: settings.febbox_key, 246 | debridToken: settings.debrid_token, 247 | debridService: settings.debrid_service, 248 | enableThumbnails: settings.enable_thumbnails, 249 | enableAutoplay: settings.enable_autoplay, 250 | enableSkipCredits: settings.enable_skip_credits, 251 | enableDiscover: settings.enable_discover, 252 | enableFeatured: settings.enable_featured, 253 | enableDetailsModal: settings.enable_details_modal, 254 | enableImageLogos: settings.enable_image_logos, 255 | enableCarouselView: settings.enable_carousel_view, 256 | forceCompactEpisodeView: settings.force_compact_episode_view, 257 | sourceOrder: settings.source_order, 258 | enableSourceOrder: settings.enable_source_order, 259 | disabledSources: settings.disabled_sources, 260 | embedOrder: settings.embed_order, 261 | enableEmbedOrder: settings.enable_embed_order, 262 | disabledEmbeds: settings.disabled_embeds, 263 | proxyTmdb: settings.proxy_tmdb, 264 | enableLowPerformanceMode: settings.enable_low_performance_mode, 265 | enableNativeSubtitles: settings.enable_native_subtitles, 266 | enableHoldToBoost: settings.enable_hold_to_boost, 267 | homeSectionOrder: settings.home_section_order, 268 | manualSourceSelection: settings.manual_source_selection, 269 | enableDoubleClickToSeek: settings.enable_double_click_to_seek, 270 | }; 271 | } catch (error) { 272 | log.error('Failed to update user settings', { 273 | userId, 274 | error: error instanceof Error ? error.message : String(error), 275 | }); 276 | 277 | if (error instanceof z.ZodError) { 278 | throw createError({ 279 | statusCode: 400, 280 | message: 'Invalid settings data', 281 | cause: error.errors, 282 | }); 283 | } 284 | 285 | throw createError({ 286 | statusCode: 500, 287 | message: 'Failed to update settings', 288 | cause: error instanceof Error ? error.message : 'Unknown error', 289 | }); 290 | } 291 | } 292 | 293 | throw createError({ 294 | statusCode: 405, 295 | message: 'Method not allowed', 296 | }); 297 | }); 298 | -------------------------------------------------------------------------------- /server/utils/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Counter, register, collectDefaultMetrics, Histogram, Summary, Registry } from 'prom-client'; 2 | import { prisma } from './prisma'; 3 | import { scopedLogger } from '~/utils/logger'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | const log = scopedLogger('metrics'); 8 | const METRICS_FILE = '.metrics.json'; 9 | const METRICS_DAILY_FILE = '.metrics_daily.json'; 10 | const METRICS_WEEKLY_FILE = '.metrics_weekly.json'; 11 | const METRICS_MONTHLY_FILE = '.metrics_monthly.json'; 12 | 13 | // Global registries 14 | const registries = { 15 | default: register, // All-time metrics (never cleared) 16 | daily: new Registry(), 17 | weekly: new Registry(), 18 | monthly: new Registry() 19 | }; 20 | 21 | // Expose the registries on global for tasks to access 22 | if (typeof global !== 'undefined') { 23 | global.metrics_daily = registries.daily; 24 | global.metrics_weekly = registries.weekly; 25 | global.metrics_monthly = registries.monthly; 26 | } 27 | 28 | export type Metrics = { 29 | user: Counter<'namespace'>; 30 | captchaSolves: Counter<'success'>; 31 | providerHostnames: Counter<'hostname'>; 32 | providerStatuses: Counter<'provider_id' | 'status'>; 33 | watchMetrics: Counter<'title' | 'tmdb_full_id' | 'provider_id' | 'success'>; 34 | toolMetrics: Counter<'tool'>; 35 | httpRequestDuration: Histogram<'method' | 'route' | 'status_code'>; 36 | httpRequestSummary: Summary<'method' | 'route' | 'status_code'>; 37 | }; 38 | 39 | // Store metrics for each time period 40 | const metricsStore: Record = { 41 | default: null, 42 | daily: null, 43 | weekly: null, 44 | monthly: null 45 | }; 46 | 47 | export function getMetrics(interval: 'default' | 'daily' | 'weekly' | 'monthly' = 'default') { 48 | if (!metricsStore[interval]) throw new Error(`metrics for ${interval} not initialized`); 49 | return metricsStore[interval]; 50 | } 51 | 52 | export function getRegistry(interval: 'default' | 'daily' | 'weekly' | 'monthly' = 'default') { 53 | return registries[interval]; 54 | } 55 | 56 | async function createMetrics(registry: Registry, interval: string): Promise { 57 | const suffix = interval !== 'default' ? `_${interval}` : ''; 58 | const newMetrics = { 59 | user: new Counter({ 60 | name: `mw_user_count${suffix}`, 61 | help: `Number of users by namespace (${interval})`, 62 | labelNames: ['namespace'], 63 | registers: [registry] 64 | }), 65 | captchaSolves: new Counter({ 66 | name: `mw_captcha_solves${suffix}`, 67 | help: `Number of captcha solves by success status (${interval})`, 68 | labelNames: ['success'], 69 | registers: [registry] 70 | }), 71 | providerHostnames: new Counter({ 72 | name: `mw_provider_hostname_count${suffix}`, 73 | help: `Number of requests by provider hostname (${interval})`, 74 | labelNames: ['hostname'], 75 | registers: [registry] 76 | }), 77 | providerStatuses: new Counter({ 78 | name: `mw_provider_status_count${suffix}`, 79 | help: `Number of provider requests by status (${interval})`, 80 | labelNames: ['provider_id', 'status'], 81 | registers: [registry] 82 | }), 83 | watchMetrics: new Counter({ 84 | name: `mw_media_watch_count${suffix}`, 85 | help: `Number of media watch events (${interval})`, 86 | labelNames: ['title', 'tmdb_full_id', 'provider_id', 'success'], 87 | registers: [registry] 88 | }), 89 | toolMetrics: new Counter({ 90 | name: `mw_provider_tool_count${suffix}`, 91 | help: `Number of provider tool usages (${interval})`, 92 | labelNames: ['tool'], 93 | registers: [registry] 94 | }), 95 | httpRequestDuration: new Histogram({ 96 | name: `http_request_duration_seconds${suffix}`, 97 | help: `request duration in seconds (${interval})`, 98 | labelNames: ['method', 'route', 'status_code'], 99 | buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], 100 | registers: [registry] 101 | }), 102 | httpRequestSummary: new Summary({ 103 | name: `http_request_summary_seconds${suffix}`, 104 | help: `request duration in seconds summary (${interval})`, 105 | labelNames: ['method', 'route', 'status_code'], 106 | percentiles: [0.01, 0.05, 0.5, 0.9, 0.95, 0.99, 0.999], 107 | registers: [registry] 108 | }), 109 | }; 110 | 111 | return newMetrics; 112 | } 113 | 114 | async function saveMetricsToFile(interval: string = 'default') { 115 | try { 116 | const registry = registries[interval]; 117 | if (!registry) return; 118 | 119 | const fileName = getMetricsFileName(interval); 120 | 121 | const metricsData = await registry.getMetricsAsJSON(); 122 | const relevantMetrics = metricsData.filter( 123 | metric => metric.name.startsWith('mw_') || metric.name.startsWith('http_request') 124 | ); 125 | 126 | fs.writeFileSync(fileName, JSON.stringify(relevantMetrics, null, 2)); 127 | 128 | log.info(`${interval} metrics saved to file`, { evt: 'metrics_saved', interval }); 129 | } catch (error) { 130 | log.error(`Failed to save ${interval} metrics`, { 131 | evt: 'save_metrics_error', 132 | interval, 133 | error: error instanceof Error ? error.message : String(error), 134 | }); 135 | } 136 | } 137 | 138 | async function loadMetricsFromFile(interval: string = 'default'): Promise { 139 | try { 140 | const fileName = getMetricsFileName(interval); 141 | 142 | if (!fs.existsSync(fileName)) { 143 | log.info(`No saved ${interval} metrics found`, { evt: 'no_saved_metrics', interval }); 144 | return []; 145 | } 146 | 147 | const data = fs.readFileSync(fileName, 'utf8'); 148 | const savedMetrics = JSON.parse(data); 149 | log.info(`Loaded saved ${interval} metrics`, { 150 | evt: 'metrics_loaded', 151 | interval, 152 | count: savedMetrics.length, 153 | }); 154 | return savedMetrics; 155 | } catch (error) { 156 | log.error(`Failed to load ${interval} metrics`, { 157 | evt: 'load_metrics_error', 158 | interval, 159 | error: error instanceof Error ? error.message : String(error), 160 | }); 161 | return []; 162 | } 163 | } 164 | 165 | // Periodically save all metrics 166 | const SAVE_INTERVAL = 60000; // Save every minute 167 | setInterval(() => { 168 | Object.keys(registries).forEach(interval => { 169 | saveMetricsToFile(interval); 170 | }); 171 | }, SAVE_INTERVAL); 172 | 173 | // Save metrics on process exit 174 | process.on('SIGTERM', async () => { 175 | log.info('Saving all metrics before exit...', { evt: 'exit_save' }); 176 | for (const interval of Object.keys(registries)) { 177 | await saveMetricsToFile(interval); 178 | } 179 | process.exit(0); 180 | }); 181 | 182 | process.on('SIGINT', async () => { 183 | log.info('Saving all metrics before exit...', { evt: 'exit_save' }); 184 | for (const interval of Object.keys(registries)) { 185 | await saveMetricsToFile(interval); 186 | } 187 | process.exit(0); 188 | }); 189 | 190 | let defaultMetricsRegistered = false; 191 | const metricsRegistered: Record = { 192 | default: false, 193 | daily: false, 194 | weekly: false, 195 | monthly: false, 196 | }; 197 | 198 | export async function setupMetrics(interval: 'default' | 'daily' | 'weekly' | 'monthly' = 'default', clear: boolean = false) { 199 | try { 200 | log.info(`Setting up ${interval} metrics...`, { evt: 'start', interval }); 201 | 202 | const registry = registries[interval]; 203 | // Only clear registry if explicitly requested (e.g., by scheduled task) 204 | let skipRestore = false; 205 | if (clear) { 206 | registry.clear(); 207 | metricsRegistered[interval] = false; // allow re-registration after clear 208 | if (interval === 'default') defaultMetricsRegistered = false; 209 | // Remove persisted snapshot so we truly start fresh for this interval 210 | try { 211 | const fileName = getMetricsFileName(interval); 212 | if (fs.existsSync(fileName)) { 213 | fs.unlinkSync(fileName); 214 | log.info(`Deleted persisted ${interval} metrics file`, { evt: 'deleted_metrics_file', interval }); 215 | } 216 | } catch (err) { 217 | log.warn(`Failed to delete ${interval} metrics file`, { 218 | evt: 'delete_metrics_file_error', 219 | interval, 220 | error: err instanceof Error ? err.message : String(err), 221 | }); 222 | } 223 | skipRestore = true; 224 | } 225 | // Only register metrics once per registry per process 226 | if (!metricsRegistered[interval]) { 227 | if (interval === 'default' && !defaultMetricsRegistered) { 228 | collectDefaultMetrics({ 229 | register: registry, 230 | prefix: '', // No prefix to match the example output 231 | eventLoopMonitoringPrecision: 1, // Ensure eventloop metrics are collected 232 | gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], // Match the example buckets 233 | }); 234 | defaultMetricsRegistered = true; 235 | } 236 | metricsStore[interval] = await createMetrics(registry, interval); 237 | metricsRegistered[interval] = true; 238 | log.info(`Created new ${interval} metrics...`, { evt: 'created', interval }); 239 | } 240 | // Load saved metrics 241 | if (!skipRestore) { 242 | const savedMetrics = await loadMetricsFromFile(interval); 243 | if (savedMetrics.length > 0) { 244 | log.info(`Restoring saved ${interval} metrics...`, { evt: 'restore_metrics', interval }); 245 | savedMetrics.forEach(metric => { 246 | if (metric.values) { 247 | metric.values.forEach(value => { 248 | const metrics = metricsStore[interval]; 249 | if (!metrics) return; 250 | 251 | // Extract the base metric name without the interval suffix 252 | const baseName = metric.name.replace(/_daily$|_weekly$|_monthly$/, ''); 253 | 254 | switch (baseName) { 255 | case 'mw_user_count': 256 | metrics.user.inc(value.labels, value.value); 257 | break; 258 | case 'mw_captcha_solves': 259 | metrics.captchaSolves.inc(value.labels, value.value); 260 | break; 261 | case 'mw_provider_hostname_count': 262 | metrics.providerHostnames.inc(value.labels, value.value); 263 | break; 264 | case 'mw_provider_status_count': 265 | metrics.providerStatuses.inc(value.labels, value.value); 266 | break; 267 | case 'mw_media_watch_count': 268 | metrics.watchMetrics.inc(value.labels, value.value); 269 | break; 270 | case 'mw_provider_tool_count': 271 | metrics.toolMetrics.inc(value.labels, value.value); 272 | break; 273 | case 'http_request_duration_seconds': 274 | // For histograms, special handling for sum and count 275 | if ( 276 | value.metricName === `http_request_duration_seconds${interval !== 'default' ? `_${interval}` : ''}sum` || 277 | value.metricName === `http_request_duration_seconds${interval !== 'default' ? `_${interval}` : ''}count` 278 | ) { 279 | metrics.httpRequestDuration.observe(value.labels, value.value); 280 | } 281 | break; 282 | } 283 | }); 284 | } 285 | }); 286 | } 287 | } 288 | 289 | // Initialize metrics with current data (best-effort; don't fail if DB is unavailable) 290 | log.info(`Syncing up ${interval} metrics...`, { evt: 'sync', interval }); 291 | try { 292 | await updateMetrics(interval); 293 | } catch (err) { 294 | log.warn(`Skipping ${interval} DB-backed metric sync due to error`, { 295 | evt: 'sync_skipped', 296 | interval, 297 | error: err instanceof Error ? err.message : String(err), 298 | }); 299 | } 300 | log.info(`${interval} metrics initialized!`, { evt: 'end', interval }); 301 | 302 | // Save initial state 303 | await saveMetricsToFile(interval); 304 | } catch (error) { 305 | log.error(`Failed to setup ${interval} metrics`, { 306 | evt: 'setup_error', 307 | interval, 308 | error: error instanceof Error ? error.message : String(error), 309 | }); 310 | // Do not rethrow so callers can keep running (e.g., allow HTTP metrics recording without DB) 311 | return; 312 | } 313 | } 314 | 315 | async function updateMetrics(interval: 'default' | 'daily' | 'weekly' | 'monthly' = 'default') { 316 | try { 317 | // Only the default (all-time) registry should be hydrated from the database. 318 | if (interval !== 'default') { 319 | return; 320 | } 321 | log.info(`Fetching users from database for ${interval} metrics...`, { evt: 'update_metrics_start', interval }); 322 | 323 | const users = await prisma.users.groupBy({ 324 | by: ['namespace'], 325 | _count: true, 326 | }); 327 | 328 | log.info('Found users', { evt: 'users_found', count: users.length, interval }); 329 | 330 | const metrics = metricsStore[interval]; 331 | if (!metrics) return; 332 | 333 | metrics.user.reset(); 334 | log.info(`Reset user metrics counter for ${interval}`, { evt: 'metrics_reset', interval }); 335 | 336 | users.forEach(v => { 337 | log.info(`Incrementing user metric for ${interval}`, { 338 | evt: 'increment_metric', 339 | interval, 340 | namespace: v.namespace, 341 | count: v._count, 342 | }); 343 | metrics.user.inc({ namespace: v.namespace }, v._count); 344 | }); 345 | 346 | log.info(`Successfully updated ${interval} metrics`, { evt: 'update_metrics_complete', interval }); 347 | } catch (error) { 348 | log.error(`Failed to update ${interval} metrics`, { 349 | evt: 'update_metrics_error', 350 | interval, 351 | error: error instanceof Error ? error.message : String(error), 352 | }); 353 | throw error; 354 | } 355 | } 356 | 357 | // Export function to record HTTP request duration for all registries 358 | export function recordHttpRequest( 359 | method: string, 360 | route: string, 361 | statusCode: number, 362 | duration: number 363 | ) { 364 | const labels = { 365 | method, 366 | route, 367 | status_code: statusCode.toString(), 368 | }; 369 | 370 | // Record in all active registries 371 | Object.entries(metricsStore).forEach(([interval, metrics]) => { 372 | if (!metrics) return; 373 | 374 | // Record in both histogram and summary 375 | metrics.httpRequestDuration.observe(labels, duration); 376 | metrics.httpRequestSummary.observe(labels, duration); 377 | }); 378 | } 379 | 380 | // Functions to match previous backend API - record in all registries 381 | export function recordProviderMetrics(items: any[], hostname: string, tool?: string) { 382 | Object.values(metricsStore).forEach(metrics => { 383 | if (!metrics) return; 384 | 385 | // Record hostname once per request 386 | metrics.providerHostnames.inc({ hostname }); 387 | 388 | // Record status metrics for each item 389 | items.forEach(item => { 390 | // Record provider status 391 | metrics.providerStatuses.inc({ 392 | provider_id: item.embedId ?? item.providerId, 393 | status: item.status, 394 | }); 395 | }); 396 | 397 | // Reverse items to get the last one, and find the last successful item 398 | const itemList = [...items]; 399 | itemList.reverse(); 400 | const lastSuccessfulItem = items.find(v => v.status === 'success'); 401 | const lastItem = itemList[0]; 402 | 403 | // Record watch metrics only for the last item 404 | if (lastItem) { 405 | metrics.watchMetrics.inc({ 406 | tmdb_full_id: lastItem.type + '-' + lastItem.tmdbId, 407 | provider_id: lastSuccessfulItem?.providerId ?? lastItem.providerId, 408 | title: lastItem.title, 409 | success: (!!lastSuccessfulItem).toString(), 410 | }); 411 | } 412 | 413 | // Record tool metrics 414 | if (tool) { 415 | metrics.toolMetrics.inc({ tool }); 416 | } 417 | }); 418 | } 419 | 420 | export function recordCaptchaMetrics(success: boolean) { 421 | Object.values(metricsStore).forEach(metrics => { 422 | metrics?.captchaSolves.inc({ success: success.toString() }); 423 | }); 424 | } 425 | 426 | // Initialize all metrics registries on startup 427 | export async function initializeAllMetrics() { 428 | for (const interval of Object.keys(registries) as Array<'default' | 'daily' | 'weekly' | 'monthly'>) { 429 | try { 430 | await setupMetrics(interval); 431 | } catch (error) { 432 | log.error(`initializeAllMetrics: failed to setup ${interval}`, { 433 | evt: 'init_interval_error', 434 | interval, 435 | error: error instanceof Error ? error.message : String(error), 436 | }); 437 | // Continue initializing other intervals 438 | } 439 | } 440 | } 441 | --------------------------------------------------------------------------------