Spaces:
Sleeping
Sleeping
aladhefafalquran commited on
Commit ·
a43472b
1
Parent(s): d19ebdd
Deploy StreamTime server
Browse files- Dockerfile +33 -15
- apps/server/esbuild.config.js +14 -0
- apps/server/package.json +36 -0
- apps/server/prisma/schema.prisma +56 -0
- apps/server/src/db.ts +3 -0
- apps/server/src/env.ts +14 -0
- apps/server/src/index.ts +35 -0
- apps/server/src/middleware/auth.ts +33 -0
- apps/server/src/routes/auth.ts +139 -0
- apps/server/src/routes/history.ts +89 -0
- apps/server/src/routes/subtitles.ts +79 -0
- apps/server/src/routes/tmdb.ts +90 -0
- apps/server/src/routes/watchlist.ts +70 -0
- apps/server/tsconfig.json +13 -0
- package.json +18 -0
- packages/shared/package.json +9 -0
- packages/shared/src/index.ts +4 -0
- packages/shared/src/types/subtitle.ts +12 -0
- packages/shared/src/types/tmdb.ts +103 -0
- packages/shared/src/types/user.ts +6 -0
- packages/shared/src/types/watchlist.ts +21 -0
- packages/shared/tsconfig.json +12 -0
- pnpm-lock.yaml +0 -0
- pnpm-workspace.yaml +3 -0
Dockerfile
CHANGED
|
@@ -1,15 +1,33 @@
|
|
| 1 |
-
FROM
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/*"
|