aladhefafalquran commited on
Commit
a43472b
·
1 Parent(s): d19ebdd

Deploy StreamTime server

Browse files
Dockerfile CHANGED
@@ -1,15 +1,33 @@
1
- FROM stremio/server:latest
2
-
3
- # Patch the hardcoded localhost URL in the bundled web UI JS files
4
- RUN find / -name "*.js" \
5
- -not -path "*/proc/*" \
6
- -not -path "*/sys/*" \
7
- 2>/dev/null \
8
- | while IFS= read -r f; do \
9
- if grep -q "127.0.0.1:11470" "$f" 2>/dev/null; then \
10
- sed -i 's|http://127.0.0.1:11470|https://xthexbeastx-stremio.hf.space|g' "$f"; \
11
- sed -i 's|http://localhost:11470|https://xthexbeastx-stremio.hf.space|g' "$f"; \
12
- fi; \
13
- done || true
14
-
15
- EXPOSE 11470
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim AS base
2
+ RUN npm install -g pnpm
3
+
4
+ WORKDIR /app
5
+
6
+ FROM base AS builder
7
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
8
+ COPY packages/shared/package.json ./packages/shared/
9
+ COPY apps/server/package.json ./apps/server/
10
+
11
+ RUN pnpm install --frozen-lockfile
12
+
13
+ COPY packages/shared ./packages/shared
14
+ COPY apps/server ./apps/server
15
+
16
+ WORKDIR /app/apps/server
17
+ RUN pnpm db:generate
18
+ RUN pnpm build
19
+
20
+ FROM node:20-slim AS runner
21
+ RUN npm install -g pnpm
22
+
23
+ WORKDIR /app
24
+
25
+ COPY --from=builder /app/apps/server/dist ./dist
26
+ COPY --from=builder /app/apps/server/node_modules ./node_modules
27
+ COPY --from=builder /app/apps/server/prisma ./prisma
28
+
29
+ ENV NODE_ENV=production
30
+ ENV PORT=7860
31
+ EXPOSE 7860
32
+
33
+ CMD ["node", "dist/index.js"]
apps/server/esbuild.config.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { build } from 'esbuild'
2
+
3
+ await build({
4
+ entryPoints: ['src/index.ts'],
5
+ bundle: true,
6
+ platform: 'node',
7
+ target: 'node20',
8
+ format: 'esm',
9
+ outdir: 'dist',
10
+ external: ['@prisma/client'],
11
+ banner: {
12
+ js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`,
13
+ },
14
+ })
apps/server/package.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@streamtime/server",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "tsx watch --env-file=.env src/index.ts",
7
+ "build": "node esbuild.config.js",
8
+ "start": "node dist/index.js",
9
+ "db:push": "prisma db push",
10
+ "db:generate": "prisma generate"
11
+ },
12
+ "dependencies": {
13
+ "@prisma/client": "^6.1.0",
14
+ "@streamtime/shared": "workspace:*",
15
+ "axios": "^1.7.9",
16
+ "bcryptjs": "^2.4.3",
17
+ "cookie-parser": "^1.4.7",
18
+ "cors": "^2.8.5",
19
+ "express": "^4.21.2",
20
+ "helmet": "^8.0.0",
21
+ "jsonwebtoken": "^9.0.2",
22
+ "zod": "^3.24.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bcryptjs": "^2.4.6",
26
+ "@types/cookie-parser": "^1.4.8",
27
+ "@types/cors": "^2.8.17",
28
+ "@types/express": "^5.0.0",
29
+ "@types/jsonwebtoken": "^9.0.7",
30
+ "@types/node": "^22.10.7",
31
+ "esbuild": "^0.24.2",
32
+ "prisma": "^6.1.0",
33
+ "tsx": "^4.19.2",
34
+ "typescript": "^5.7.3"
35
+ }
36
+ }
apps/server/prisma/schema.prisma ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ }
4
+
5
+ datasource db {
6
+ provider = "postgresql"
7
+ url = env("DATABASE_URL")
8
+ directUrl = env("DIRECT_URL")
9
+ }
10
+
11
+ model User {
12
+ id String @id @default(cuid())
13
+ email String @unique
14
+ username String @unique
15
+ passwordHash String
16
+ createdAt DateTime @default(now())
17
+ sessions Session[]
18
+ watchlist Watchlist[]
19
+ watchHistory WatchHistory[]
20
+ }
21
+
22
+ model Session {
23
+ id String @id @default(cuid())
24
+ userId String
25
+ token String @unique
26
+ expiresAt DateTime
27
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
28
+ }
29
+
30
+ model Watchlist {
31
+ id String @id @default(cuid())
32
+ userId String
33
+ tmdbId Int
34
+ mediaType String
35
+ title String
36
+ posterPath String?
37
+ addedAt DateTime @default(now())
38
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
39
+
40
+ @@unique([userId, tmdbId, mediaType])
41
+ }
42
+
43
+ model WatchHistory {
44
+ id String @id @default(cuid())
45
+ userId String
46
+ tmdbId Int
47
+ mediaType String
48
+ title String
49
+ posterPath String?
50
+ seasonNumber Int?
51
+ episodeNumber Int?
52
+ progressSeconds Int @default(0)
53
+ durationSeconds Int @default(0)
54
+ updatedAt DateTime @updatedAt
55
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
56
+ }
apps/server/src/db.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import { PrismaClient } from '@prisma/client'
2
+
3
+ export const prisma = new PrismaClient()
apps/server/src/env.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from 'zod'
2
+
3
+ const envSchema = z.object({
4
+ DATABASE_URL: z.string(),
5
+ DIRECT_URL: z.string().optional(),
6
+ JWT_SECRET: z.string(),
7
+ TMDB_API_KEY: z.string(),
8
+ OPENSUBTITLES_API_KEY: z.string(),
9
+ CLIENT_URL: z.string().default('http://localhost:5173'),
10
+ PORT: z.coerce.number().default(3001),
11
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
12
+ })
13
+
14
+ export const env = envSchema.parse(process.env)
apps/server/src/index.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express'
2
+ import cors from 'cors'
3
+ import helmet from 'helmet'
4
+ import cookieParser from 'cookie-parser'
5
+ import { env } from './env.js'
6
+ import authRouter from './routes/auth.js'
7
+ import tmdbRouter from './routes/tmdb.js'
8
+ import subtitlesRouter from './routes/subtitles.js'
9
+ import watchlistRouter from './routes/watchlist.js'
10
+ import historyRouter from './routes/history.js'
11
+
12
+ const app = express()
13
+
14
+ app.use(helmet({
15
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
16
+ contentSecurityPolicy: false,
17
+ }))
18
+
19
+ app.use(cors({
20
+ origin: env.CLIENT_URL,
21
+ credentials: true,
22
+ }))
23
+
24
+ app.use(express.json())
25
+ app.use(cookieParser())
26
+
27
+ app.use('/api/auth', authRouter)
28
+ app.use('/api/tmdb', tmdbRouter)
29
+ app.use('/api/subtitles', subtitlesRouter)
30
+ app.use('/api/watchlist', watchlistRouter)
31
+ app.use('/api/history', historyRouter)
32
+
33
+ app.listen(env.PORT, () => {
34
+ console.log(`Server listening on port ${env.PORT}`)
35
+ })
apps/server/src/middleware/auth.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Request, Response, NextFunction } from 'express'
2
+ import jwt from 'jsonwebtoken'
3
+ import crypto from 'crypto'
4
+ import { prisma } from '../db.js'
5
+ import { env } from '../env.js'
6
+
7
+ export interface AuthRequest extends Request {
8
+ userId?: string
9
+ }
10
+
11
+ export async function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
12
+ const token = req.cookies.st_token
13
+ if (!token) {
14
+ res.status(401).json({ error: 'Unauthorized' })
15
+ return
16
+ }
17
+
18
+ try {
19
+ const payload = jwt.verify(token, env.JWT_SECRET) as { sub: string; jti: string }
20
+ const hash = crypto.createHash('sha256').update(payload.jti).digest('hex')
21
+
22
+ const session = await prisma.session.findUnique({ where: { token: hash } })
23
+ if (!session || session.expiresAt < new Date()) {
24
+ res.status(401).json({ error: 'Session expired' })
25
+ return
26
+ }
27
+
28
+ req.userId = payload.sub
29
+ next()
30
+ } catch {
31
+ res.status(401).json({ error: 'Invalid token' })
32
+ }
33
+ }
apps/server/src/routes/auth.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express'
2
+ import bcrypt from 'bcryptjs'
3
+ import jwt from 'jsonwebtoken'
4
+ import crypto from 'crypto'
5
+ import { prisma } from '../db.js'
6
+ import { env } from '../env.js'
7
+ import { requireAuth, AuthRequest } from '../middleware/auth.js'
8
+
9
+ const router: Router = Router()
10
+
11
+ const COOKIE_OPTS = {
12
+ httpOnly: true,
13
+ secure: env.NODE_ENV === 'production',
14
+ sameSite: 'lax' as const,
15
+ maxAge: 7 * 24 * 60 * 60 * 1000,
16
+ }
17
+
18
+ function signToken(userId: string) {
19
+ const jti = crypto.randomUUID()
20
+ const token = jwt.sign({ sub: userId, jti }, env.JWT_SECRET, { expiresIn: '7d' })
21
+ const hash = crypto.createHash('sha256').update(jti).digest('hex')
22
+ return { token, hash }
23
+ }
24
+
25
+ router.post('/register', async (req, res) => {
26
+ const { email, username, password } = req.body
27
+ if (!email || !username || !password) {
28
+ res.status(400).json({ error: 'Missing fields' })
29
+ return
30
+ }
31
+
32
+ const existing = await prisma.user.findFirst({
33
+ where: { OR: [{ email }, { username }] },
34
+ })
35
+ if (existing) {
36
+ res.status(409).json({ error: 'Email or username already taken' })
37
+ return
38
+ }
39
+
40
+ const passwordHash = await bcrypt.hash(password, 12)
41
+ const user = await prisma.user.create({
42
+ data: { email, username, passwordHash },
43
+ })
44
+
45
+ const { token, hash } = signToken(user.id)
46
+ await prisma.session.create({
47
+ data: {
48
+ userId: user.id,
49
+ token: hash,
50
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
51
+ },
52
+ })
53
+
54
+ res.cookie('st_token', token, COOKIE_OPTS)
55
+ res.json({ user: { id: user.id, email: user.email, username: user.username } })
56
+ })
57
+
58
+ router.post('/login', async (req, res) => {
59
+ const { email, password } = req.body
60
+ if (!email || !password) {
61
+ res.status(400).json({ error: 'Missing fields' })
62
+ return
63
+ }
64
+
65
+ const user = await prisma.user.findUnique({ where: { email } })
66
+ if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
67
+ res.status(401).json({ error: 'Invalid credentials' })
68
+ return
69
+ }
70
+
71
+ const { token, hash } = signToken(user.id)
72
+ await prisma.session.create({
73
+ data: {
74
+ userId: user.id,
75
+ token: hash,
76
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
77
+ },
78
+ })
79
+
80
+ res.cookie('st_token', token, COOKIE_OPTS)
81
+ res.json({ user: { id: user.id, email: user.email, username: user.username } })
82
+ })
83
+
84
+ router.post('/logout', requireAuth as any, async (req: AuthRequest, res) => {
85
+ const token = req.cookies.st_token
86
+ if (token) {
87
+ try {
88
+ const payload = jwt.verify(token, env.JWT_SECRET) as { jti: string }
89
+ const hash = crypto.createHash('sha256').update(payload.jti).digest('hex')
90
+ await prisma.session.deleteMany({ where: { token: hash } })
91
+ } catch { /* ignore */ }
92
+ }
93
+ res.clearCookie('st_token')
94
+ res.json({ ok: true })
95
+ })
96
+
97
+ router.get('/me', requireAuth as any, async (req: AuthRequest, res) => {
98
+ const user = await prisma.user.findUnique({
99
+ where: { id: req.userId },
100
+ select: { id: true, email: true, username: true, createdAt: true },
101
+ })
102
+ if (!user) {
103
+ res.status(404).json({ error: 'User not found' })
104
+ return
105
+ }
106
+ res.json({ user })
107
+ })
108
+
109
+ router.get('/stats', requireAuth as any, async (req: AuthRequest, res) => {
110
+ const [watchlistCount, historyCount] = await Promise.all([
111
+ prisma.watchlist.count({ where: { userId: req.userId! } }),
112
+ prisma.watchHistory.count({ where: { userId: req.userId! } }),
113
+ ])
114
+ res.json({ watchlistCount, historyCount })
115
+ })
116
+
117
+ router.post('/password', requireAuth as any, async (req: AuthRequest, res) => {
118
+ const { currentPassword, newPassword } = req.body
119
+ if (!currentPassword || !newPassword) {
120
+ res.status(400).json({ error: 'Missing fields' })
121
+ return
122
+ }
123
+ if (newPassword.length < 6) {
124
+ res.status(400).json({ error: 'New password must be at least 6 characters' })
125
+ return
126
+ }
127
+
128
+ const user = await prisma.user.findUnique({ where: { id: req.userId! } })
129
+ if (!user || !(await bcrypt.compare(currentPassword, user.passwordHash))) {
130
+ res.status(401).json({ error: 'Current password is incorrect' })
131
+ return
132
+ }
133
+
134
+ const passwordHash = await bcrypt.hash(newPassword, 12)
135
+ await prisma.user.update({ where: { id: req.userId! }, data: { passwordHash } })
136
+ res.json({ ok: true })
137
+ })
138
+
139
+ export default router
apps/server/src/routes/history.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express'
2
+ import { prisma } from '../db.js'
3
+ import { requireAuth, AuthRequest } from '../middleware/auth.js'
4
+
5
+ const router: Router = Router()
6
+
7
+ router.use(requireAuth as any)
8
+
9
+ router.get('/', async (req: AuthRequest, res) => {
10
+ const items = await prisma.watchHistory.findMany({
11
+ where: { userId: req.userId },
12
+ orderBy: { updatedAt: 'desc' },
13
+ })
14
+ res.json(items)
15
+ })
16
+
17
+ router.post('/', async (req: AuthRequest, res) => {
18
+ const { tmdbId, mediaType, title, posterPath, seasonNumber, episodeNumber, progressSeconds, durationSeconds } = req.body
19
+ if (!tmdbId || !mediaType || !title) {
20
+ res.status(400).json({ error: 'Missing fields' })
21
+ return
22
+ }
23
+
24
+ const existing = await prisma.watchHistory.findFirst({
25
+ where: {
26
+ userId: req.userId!,
27
+ tmdbId: Number(tmdbId),
28
+ mediaType,
29
+ seasonNumber: seasonNumber ?? null,
30
+ episodeNumber: episodeNumber ?? null,
31
+ },
32
+ })
33
+
34
+ if (existing) {
35
+ const updated = await prisma.watchHistory.update({
36
+ where: { id: existing.id },
37
+ data: {
38
+ progressSeconds: Number(progressSeconds) || 0,
39
+ durationSeconds: Number(durationSeconds) || 0,
40
+ posterPath: posterPath ?? existing.posterPath,
41
+ },
42
+ })
43
+ res.json(updated)
44
+ } else {
45
+ const item = await prisma.watchHistory.create({
46
+ data: {
47
+ userId: req.userId!,
48
+ tmdbId: Number(tmdbId),
49
+ mediaType,
50
+ title,
51
+ posterPath: posterPath ?? null,
52
+ seasonNumber: seasonNumber ?? null,
53
+ episodeNumber: episodeNumber ?? null,
54
+ progressSeconds: Number(progressSeconds) || 0,
55
+ durationSeconds: Number(durationSeconds) || 0,
56
+ },
57
+ })
58
+ res.status(201).json(item)
59
+ }
60
+ })
61
+
62
+ router.delete('/:id', async (req: AuthRequest, res) => {
63
+ const { id } = req.params
64
+ await prisma.watchHistory.deleteMany({
65
+ where: { id, userId: req.userId! },
66
+ })
67
+ res.json({ ok: true })
68
+ })
69
+
70
+ router.get('/progress', async (req: AuthRequest, res) => {
71
+ const { tmdbId, mediaType, season, episode } = req.query as Record<string, string>
72
+ if (!tmdbId || !mediaType) {
73
+ res.status(400).json({ error: 'Missing fields' })
74
+ return
75
+ }
76
+
77
+ const item = await prisma.watchHistory.findFirst({
78
+ where: {
79
+ userId: req.userId!,
80
+ tmdbId: Number(tmdbId),
81
+ mediaType,
82
+ seasonNumber: season ? Number(season) : null,
83
+ episodeNumber: episode ? Number(episode) : null,
84
+ },
85
+ })
86
+ res.json(item ?? null)
87
+ })
88
+
89
+ export default router
apps/server/src/routes/subtitles.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express'
2
+ import axios from 'axios'
3
+ import { env } from '../env.js'
4
+ import type { SubtitleTrack } from '@streamtime/shared'
5
+
6
+ const router: Router = Router()
7
+
8
+ const OS_API = 'https://api.opensubtitles.com/api/v1'
9
+ const osHeaders = {
10
+ 'Api-Key': env.OPENSUBTITLES_API_KEY,
11
+ 'Content-Type': 'application/json',
12
+ 'User-Agent': 'StreamTime v1.0',
13
+ }
14
+
15
+ const subtitleCache = new Map<string, string>()
16
+
17
+ router.get('/search', async (req, res) => {
18
+ const { imdb_id, type, languages } = req.query as Record<string, string>
19
+ if (!imdb_id) {
20
+ res.status(400).json({ error: 'Missing imdb_id' })
21
+ return
22
+ }
23
+
24
+ const params = new URLSearchParams()
25
+ params.set('imdb_id', imdb_id.replace(/^tt/, ''))
26
+ if (type === 'tv') params.set('type', 'episode')
27
+ else params.set('type', 'movie')
28
+ if (languages) params.set('languages', languages)
29
+
30
+ const response = await axios.get(`${OS_API}/subtitles?${params}`, { headers: osHeaders })
31
+ const results: SubtitleTrack[] = (response.data.data || [])
32
+ .filter((item: any) => item.attributes?.files?.length > 0)
33
+ .map((item: any) => ({
34
+ fileId: String(item.attributes.files[0].file_id),
35
+ language: item.attributes.language,
36
+ languageName: item.attributes.language,
37
+ releaseName: item.attributes.release || '',
38
+ }))
39
+
40
+ res.json(results)
41
+ })
42
+
43
+ router.get('/download/:fileId', async (req, res) => {
44
+ const { fileId } = req.params
45
+
46
+ if (subtitleCache.has(fileId)) {
47
+ res.setHeader('Content-Type', 'text/vtt')
48
+ res.send(subtitleCache.get(fileId))
49
+ return
50
+ }
51
+
52
+ const downloadRes = await axios.post(
53
+ `${OS_API}/download`,
54
+ { file_id: parseInt(fileId) },
55
+ { headers: osHeaders }
56
+ )
57
+
58
+ const fileUrl: string = downloadRes.data.link
59
+ const fileRes = await axios.get(fileUrl, { responseType: 'text' })
60
+ let content: string = fileRes.data
61
+
62
+ if (!content.startsWith('WEBVTT')) {
63
+ content = srtToVtt(content)
64
+ }
65
+
66
+ subtitleCache.set(fileId, content)
67
+ res.setHeader('Content-Type', 'text/vtt')
68
+ res.send(content)
69
+ })
70
+
71
+ function srtToVtt(srt: string): string {
72
+ const vtt = srt
73
+ .replace(/\r\n/g, '\n')
74
+ .replace(/\r/g, '\n')
75
+ .replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, '$1.$2')
76
+ return 'WEBVTT\n\n' + vtt
77
+ }
78
+
79
+ export default router
apps/server/src/routes/tmdb.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express'
2
+ import axios from 'axios'
3
+ import { env } from '../env.js'
4
+
5
+ const router: Router = Router()
6
+
7
+ const tmdb = axios.create({
8
+ baseURL: 'https://api.themoviedb.org/3',
9
+ headers: {
10
+ Authorization: `Bearer ${env.TMDB_API_KEY}`,
11
+ 'Content-Type': 'application/json',
12
+ },
13
+ })
14
+
15
+ async function proxyGet(path: string, params?: Record<string, string>) {
16
+ const res = await tmdb.get(path, { params })
17
+ return res.data
18
+ }
19
+
20
+ router.get('/trending', async (_req, res) => {
21
+ const data = await proxyGet('/trending/all/week')
22
+ res.json(data)
23
+ })
24
+
25
+ router.get('/movies/popular', async (_req, res) => {
26
+ const data = await proxyGet('/movie/popular')
27
+ res.json(data)
28
+ })
29
+
30
+ router.get('/movies/top-rated', async (_req, res) => {
31
+ const data = await proxyGet('/movie/top_rated')
32
+ res.json(data)
33
+ })
34
+
35
+ router.get('/tv/popular', async (_req, res) => {
36
+ const data = await proxyGet('/tv/popular')
37
+ res.json(data)
38
+ })
39
+
40
+ router.get('/movies/:id', async (req, res) => {
41
+ const data = await proxyGet(`/movie/${req.params.id}`, {
42
+ append_to_response: 'videos,credits,external_ids',
43
+ })
44
+ res.json(data)
45
+ })
46
+
47
+ router.get('/tv/:id', async (req, res) => {
48
+ const data = await proxyGet(`/tv/${req.params.id}`, {
49
+ append_to_response: 'external_ids,seasons',
50
+ })
51
+ res.json(data)
52
+ })
53
+
54
+ router.get('/tv/:id/season/:season', async (req, res) => {
55
+ const data = await proxyGet(`/tv/${req.params.id}/season/${req.params.season}`)
56
+ res.json(data)
57
+ })
58
+
59
+ router.get('/movies/:id/similar', async (req, res) => {
60
+ const data = await proxyGet(`/movie/${req.params.id}/recommendations`)
61
+ res.json(data)
62
+ })
63
+
64
+ router.get('/tv/:id/similar', async (req, res) => {
65
+ const data = await proxyGet(`/tv/${req.params.id}/recommendations`)
66
+ res.json(data)
67
+ })
68
+
69
+ router.get('/discover', async (req, res) => {
70
+ const { type, genreId, page = '1' } = req.query as Record<string, string>
71
+ const endpoint = type === 'tv' ? '/discover/tv' : '/discover/movie'
72
+ const data = await proxyGet(endpoint, {
73
+ with_genres: genreId,
74
+ page,
75
+ sort_by: 'popularity.desc',
76
+ })
77
+ res.json(data)
78
+ })
79
+
80
+ router.get('/search', async (req, res) => {
81
+ const q = req.query.q as string
82
+ if (!q) {
83
+ res.status(400).json({ error: 'Missing q parameter' })
84
+ return
85
+ }
86
+ const data = await proxyGet('/search/multi', { query: q })
87
+ res.json(data)
88
+ })
89
+
90
+ export default router
apps/server/src/routes/watchlist.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express'
2
+ import { prisma } from '../db.js'
3
+ import { requireAuth, AuthRequest } from '../middleware/auth.js'
4
+
5
+ const router: Router = Router()
6
+
7
+ router.use(requireAuth as any)
8
+
9
+ router.get('/', async (req: AuthRequest, res) => {
10
+ const items = await prisma.watchlist.findMany({
11
+ where: { userId: req.userId },
12
+ orderBy: { addedAt: 'desc' },
13
+ })
14
+ res.json(items)
15
+ })
16
+
17
+ router.post('/', async (req: AuthRequest, res) => {
18
+ const { tmdbId, mediaType, title, posterPath } = req.body
19
+ if (!tmdbId || !mediaType || !title) {
20
+ res.status(400).json({ error: 'Missing fields' })
21
+ return
22
+ }
23
+
24
+ const existing = await prisma.watchlist.findFirst({
25
+ where: { userId: req.userId!, tmdbId: Number(tmdbId), mediaType },
26
+ })
27
+ if (existing) {
28
+ res.json(existing)
29
+ return
30
+ }
31
+
32
+ const item = await prisma.watchlist.create({
33
+ data: {
34
+ userId: req.userId!,
35
+ tmdbId: Number(tmdbId),
36
+ mediaType,
37
+ title,
38
+ posterPath: posterPath ?? null,
39
+ },
40
+ })
41
+ res.status(201).json(item)
42
+ })
43
+
44
+ router.delete('/', async (req: AuthRequest, res) => {
45
+ const { tmdbId, mediaType } = req.body
46
+ if (!tmdbId || !mediaType) {
47
+ res.status(400).json({ error: 'Missing fields' })
48
+ return
49
+ }
50
+
51
+ await prisma.watchlist.deleteMany({
52
+ where: { userId: req.userId!, tmdbId: Number(tmdbId), mediaType },
53
+ })
54
+ res.json({ ok: true })
55
+ })
56
+
57
+ router.get('/check', async (req: AuthRequest, res) => {
58
+ const { tmdbId, mediaType } = req.query as Record<string, string>
59
+ if (!tmdbId || !mediaType) {
60
+ res.status(400).json({ error: 'Missing fields' })
61
+ return
62
+ }
63
+
64
+ const item = await prisma.watchlist.findFirst({
65
+ where: { userId: req.userId!, tmdbId: Number(tmdbId), mediaType },
66
+ })
67
+ res.json({ inWatchlist: !!item })
68
+ })
69
+
70
+ export default router
apps/server/tsconfig.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src"
11
+ },
12
+ "include": ["src"]
13
+ }
package.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "streamtime",
3
+ "private": true,
4
+ "scripts": {
5
+ "dev": "turbo run dev",
6
+ "build": "turbo run build",
7
+ "lint": "turbo run lint",
8
+ "type-check": "turbo run type-check"
9
+ },
10
+ "devDependencies": {
11
+ "turbo": "^2.3.3",
12
+ "typescript": "^5.7.3"
13
+ },
14
+ "packageManager": "pnpm@9.15.4",
15
+ "engines": {
16
+ "node": ">=20"
17
+ }
18
+ }
packages/shared/package.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@streamtime/shared",
3
+ "version": "0.0.1",
4
+ "main": "./src/index.ts",
5
+ "types": "./src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ }
9
+ }
packages/shared/src/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export * from './types/tmdb'
2
+ export * from './types/user'
3
+ export * from './types/watchlist'
4
+ export * from './types/subtitle'
packages/shared/src/types/subtitle.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface SubtitleTrack {
2
+ fileId: string
3
+ language: string
4
+ languageName: string
5
+ releaseName: string
6
+ }
7
+
8
+ export interface SubtitleCue {
9
+ start: number
10
+ end: number
11
+ text: string
12
+ }
packages/shared/src/types/tmdb.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface TMDBMovie {
2
+ id: number
3
+ title: string
4
+ overview: string
5
+ poster_path: string | null
6
+ backdrop_path: string | null
7
+ release_date: string
8
+ vote_average: number
9
+ vote_count: number
10
+ genre_ids?: number[]
11
+ genres?: { id: number; name: string }[]
12
+ runtime?: number
13
+ tagline?: string
14
+ status?: string
15
+ external_ids?: { imdb_id?: string }
16
+ videos?: { results: TMDBVideo[] }
17
+ credits?: { cast: TMDBCast[]; crew: TMDBCrew[] }
18
+ }
19
+
20
+ export interface TMDBTVShow {
21
+ id: number
22
+ name: string
23
+ overview: string
24
+ poster_path: string | null
25
+ backdrop_path: string | null
26
+ first_air_date: string
27
+ vote_average: number
28
+ vote_count: number
29
+ genre_ids?: number[]
30
+ genres?: { id: number; name: string }[]
31
+ number_of_seasons?: number
32
+ seasons?: TMDBSeason[]
33
+ tagline?: string
34
+ status?: string
35
+ external_ids?: { imdb_id?: string }
36
+ }
37
+
38
+ export interface TMDBSeason {
39
+ id: number
40
+ season_number: number
41
+ name: string
42
+ episode_count: number
43
+ poster_path: string | null
44
+ air_date: string
45
+ episodes?: TMDBEpisode[]
46
+ }
47
+
48
+ export interface TMDBEpisode {
49
+ id: number
50
+ name: string
51
+ overview: string
52
+ episode_number: number
53
+ season_number: number
54
+ air_date: string
55
+ still_path: string | null
56
+ vote_average: number
57
+ runtime?: number
58
+ }
59
+
60
+ export interface TMDBVideo {
61
+ id: string
62
+ key: string
63
+ name: string
64
+ site: string
65
+ type: string
66
+ official: boolean
67
+ }
68
+
69
+ export interface TMDBCast {
70
+ id: number
71
+ name: string
72
+ character: string
73
+ profile_path: string | null
74
+ order: number
75
+ }
76
+
77
+ export interface TMDBCrew {
78
+ id: number
79
+ name: string
80
+ job: string
81
+ department: string
82
+ profile_path: string | null
83
+ }
84
+
85
+ export interface TMDBSearchResult {
86
+ id: number
87
+ media_type: 'movie' | 'tv' | 'person'
88
+ title?: string
89
+ name?: string
90
+ poster_path: string | null
91
+ backdrop_path: string | null
92
+ release_date?: string
93
+ first_air_date?: string
94
+ vote_average: number
95
+ overview: string
96
+ }
97
+
98
+ export interface TMDBPaginatedResponse<T> {
99
+ page: number
100
+ results: T[]
101
+ total_pages: number
102
+ total_results: number
103
+ }
packages/shared/src/types/user.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export interface User {
2
+ id: string
3
+ email: string
4
+ username: string
5
+ createdAt?: string
6
+ }
packages/shared/src/types/watchlist.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface WatchlistItem {
2
+ id: string
3
+ tmdbId: number
4
+ mediaType: 'movie' | 'tv'
5
+ title: string
6
+ posterPath: string | null
7
+ addedAt: string
8
+ }
9
+
10
+ export interface WatchHistoryItem {
11
+ id: string
12
+ tmdbId: number
13
+ mediaType: 'movie' | 'tv'
14
+ title: string
15
+ posterPath: string | null
16
+ seasonNumber: number | null
17
+ episodeNumber: number | null
18
+ progressSeconds: number
19
+ durationSeconds: number
20
+ updatedAt: string
21
+ }
packages/shared/tsconfig.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "declaration": true
10
+ },
11
+ "include": ["src"]
12
+ }
pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
pnpm-workspace.yaml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ packages:
2
+ - "apps/*"
3
+ - "packages/*"