Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ TMDB_API_KEY=''
TRAKT_CLIENT_ID=''
TRAKT_SECRET_ID=''

# Optional: PostgreSQL connection pool size (default: 30)
# Is an integar, only set this for optimisation other wise leave blank
DB_POOL_MAX=

# Optional: Captcha
CAPTCHA=false
CAPTCHA_CLIENT_KEY=''
3,131 changes: 2,246 additions & 885 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,23 @@
"eslint-plugin-prettier": "^5.4.0",
"nitropack": "latest",
"prettier": "^3.5.3",
"prisma": "^7.0.1"
"prisma": "^7.0.1",
"rollup": "^4.59.0"
},
"dependencies": {
"@prisma/adapter-pg": "^7.0.1",
"@prisma/client": "^7.0.1",
"@types/pg": "^8.18.0",
"cheerio": "^1.0.0",
"dotenv": "^16.4.7",
"jsonwebtoken": "^9.0.2",
"p-limit": "^7.3.0",
"pg": "^8.19.0",
"prom-client": "^15.1.3",
"tmdb-ts": "^2.0.1",
"trakt.tv": "^8.2.0",
"tweetnacl": "^1.0.3",
"uuidv7": "^1.1.0",
"whatwg-url": "^14.2.0",
"zod": "^3.24.2"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
Warnings:

- A unique constraint covering the columns `[user_id,name]` on the table `lists` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[user,device]` on the table `sessions` will be added. If there are existing duplicate values, this will fail.

*/
-- CreateIndex
CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING HASH ("user_id");

-- CreateIndex
CREATE UNIQUE INDEX "lists_user_id_name_unique" ON "lists"("user_id", "name");

-- CreateIndex
CREATE INDEX "progress_items_user_id_idx" ON "progress_items" USING HASH ("user_id");

-- CreateIndex
CREATE INDEX "sessions_user_idx" ON "sessions" USING HASH ("user");

-- CreateIndex
CREATE UNIQUE INDEX "sessions_user_device_unique" ON "sessions"("user", "device");

-- CreateIndex
CREATE INDEX "watch_history_user_id_watched_at_idx" ON "watch_history"("user_id", "watched_at" DESC);
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- Normalize existing movie progress_items rows: NULL → '\n' sentinel
-- This ensures the composite unique constraint (tmdb_id, user_id, season_id, episode_id)
-- works correctly for movies (PostgreSQL treats NULL != NULL in UNIQUE indexes).

-- First, deduplicate: if both a NULL row and a '\n' row exist for the same movie,
-- keep the '\n' row (more recently written) and delete the NULL one.
DELETE FROM "progress_items" a
USING "progress_items" b
WHERE a."tmdb_id" = b."tmdb_id"
AND a."user_id" = b."user_id"
AND a."season_id" IS NULL
AND a."episode_id" IS NULL
AND b."season_id" = E'\n'
AND b."episode_id" = E'\n';

-- Now convert remaining NULL rows to '\n' (covers both fully-NULL and mixed cases)
UPDATE "progress_items"
SET "season_id" = E'\n'
WHERE "season_id" IS NULL;

UPDATE "progress_items"
SET "episode_id" = E'\n'
WHERE "episode_id" IS NULL;

-- Normalize existing movie watch_history rows: NULL → '\n' sentinel
-- Same pattern as progress_items above.

-- Deduplicate: if both a NULL row and a '\n' row exist for the same movie,
-- keep the '\n' row and delete the NULL one.
DELETE FROM "watch_history" a
USING "watch_history" b
WHERE a."tmdb_id" = b."tmdb_id"
AND a."user_id" = b."user_id"
AND a."season_id" IS NULL
AND a."episode_id" IS NULL
AND b."season_id" = E'\n'
AND b."episode_id" = E'\n';

-- Convert remaining NULL rows to '\n'
UPDATE "watch_history"
SET "season_id" = E'\n'
WHERE "season_id" IS NULL;

UPDATE "watch_history"
SET "episode_id" = E'\n'
WHERE "episode_id" IS NULL;
14 changes: 11 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
generator client {
provider = "prisma-client"
output = "../generated"
moduleFormat = "esm"
provider = "prisma-client"
output = "../generated"
moduleFormat = "esm"
previewFeatures = ["relationJoins"]
}

datasource db {
Expand All @@ -18,6 +19,7 @@ model bookmarks {

@@id([tmdb_id, user_id])
@@unique([tmdb_id, user_id], map: "bookmarks_tmdb_id_user_id_unique")
@@index([user_id], type: Hash)
}

model challenge_codes {
Expand Down Expand Up @@ -49,6 +51,7 @@ model lists {
public Boolean @default(false)
list_items list_items[]

@@unique([user_id, name], map: "lists_user_id_name_unique")
@@index([user_id], map: "lists_user_id_index")
}

Expand All @@ -72,6 +75,7 @@ model progress_items {
episode_number Int?

@@unique([tmdb_id, user_id, season_id, episode_id], map: "progress_items_tmdb_id_user_id_season_id_episode_id_unique")
@@index([user_id], type: Hash)
}

model sessions {
Expand All @@ -82,6 +86,9 @@ model sessions {
expires_at DateTime @db.Timestamptz(0)
device String
user_agent String

@@unique([user, device], map: "sessions_user_device_unique")
@@index([user], type: Hash)
}

model user_group_order {
Expand Down Expand Up @@ -158,4 +165,5 @@ model watch_history {
updated_at DateTime @default(now()) @db.Timestamptz(0)

@@unique([tmdb_id, user_id, season_id, episode_id], map: "watch_history_tmdb_id_user_id_season_id_episode_id_unique")
@@index([user_id, watched_at(sort: Desc)])
}
1 change: 1 addition & 0 deletions server/routes/auth/login/start/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineEventHandler(async event => {

const user = await prisma.users.findUnique({
where: { public_key: body.publicKey },
select: { id: true },
});

if (!user) {
Expand Down
4 changes: 2 additions & 2 deletions server/routes/auth/register/complete.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { useChallenge } from '~/utils/challenge';
import { useAuth } from '~/utils/auth';
import { randomUUID } from 'crypto';
import { uuidv7 } from 'uuidv7';
import { generateRandomNickname } from '~/utils/nickname';

const completeSchema = z.object({
Expand Down Expand Up @@ -50,7 +50,7 @@ export default defineEventHandler(async event => {
});
}

const userId = randomUUID();
const userId = uuidv7();
const now = new Date();
const nickname = generateRandomNickname();

Expand Down
1 change: 1 addition & 0 deletions server/routes/lists/[id].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { prisma } from '#imports';
export default defineEventHandler(async event => {
const id = event.context.params?.id;
const listInfo = await prisma.lists.findUnique({
relationLoadStrategy: 'join',
where: {
id: id,
},
Expand Down
39 changes: 18 additions & 21 deletions server/routes/sessions/[sid]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,25 @@ export default defineEventHandler(async event => {
const body = await readBody(event);
const validatedBody = updateSessionSchema.parse(body);

if (validatedBody.deviceName) {
await prisma.sessions.update({
where: { id: sessionId },
data: {
device: validatedBody.deviceName,
},
});
// Use update return value directly — no redundant findUnique
let updatedSession;
try {
updatedSession = validatedBody.deviceName
? await prisma.sessions.update({
where: { id: sessionId },
data: { device: validatedBody.deviceName },
})
: targetedSession;
} catch (err: any) {
if (err.code === 'P2002') {
throw createError({
statusCode: 409,
message: 'A session with this device name already exists',
});
}
throw err;
}

const updatedSession = await prisma.sessions.findUnique({
where: { id: sessionId },
});

return {
id: updatedSession.id,
user: updatedSession.user,
Expand All @@ -65,16 +71,7 @@ export default defineEventHandler(async event => {
}

if (event.method === 'DELETE') {
const sid = event.context.params?.sid;
const sessionExists = await prisma.sessions.findUnique({
where: { id: sid },
});

if (!sessionExists) {
return { success: true };
}
const session = await useAuth().getSessionAndBump(sid);

// targetedSession already validated above — no redundant findUnique or session bump needed
await prisma.sessions.delete({
where: { id: sessionId },
});
Expand Down
8 changes: 8 additions & 0 deletions server/routes/users/@me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export default defineEventHandler(async event => {

const user = await prisma.users.findUnique({
where: { id: session.user },
select: {
id: true,
public_key: true,
namespace: true,
nickname: true,
profile: true,
permissions: true,
},
});

if (!user) {
Expand Down
42 changes: 24 additions & 18 deletions server/routes/users/[id]/bookmarks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useAuth } from '~/utils/auth';
import { z } from 'zod';
import { bookmarks } from '@prisma/client';

const bookmarkMetaSchema = z.object({
title: z.string(),
Expand Down Expand Up @@ -32,9 +31,16 @@ export default defineEventHandler(async event => {
if (method === 'GET') {
const bookmarks = await prisma.bookmarks.findMany({
where: { user_id: userId },
select: {
tmdb_id: true,
meta: true,
group: true,
favorite_episodes: true,
updated_at: true,
},
});

return bookmarks.map((bookmark: bookmarks) => ({
return bookmarks.map((bookmark: any) => ({
tmdbId: bookmark.tmdb_id,
meta: bookmark.meta,
group: bookmark.group,
Expand All @@ -45,21 +51,19 @@ export default defineEventHandler(async event => {

if (method === 'PUT') {
const body = await readBody(event);
const validatedBody = z.array(bookmarkDataSchema).parse(body);
const validatedBody = z.array(bookmarkDataSchema).max(1000).parse(body);

const now = new Date();
const results = [];

for (const item of validatedBody) {
const upserts = validatedBody.map((item: any) => {
// Normalize group to always be an array
const normalizedGroup = item.group
const normalizedGroup = item.group
? (Array.isArray(item.group) ? item.group : [item.group])
: [];

// Normalize favoriteEpisodes to always be an array
const normalizedFavoriteEpisodes = item.favoriteEpisodes || [];

const bookmark = await prisma.bookmarks.upsert({
return prisma.bookmarks.upsert({
where: {
tmdb_id_user_id: {
tmdb_id: item.tmdbId,
Expand All @@ -80,18 +84,20 @@ export default defineEventHandler(async event => {
favorite_episodes: normalizedFavoriteEpisodes,
updated_at: now,
} as any,
}) as bookmarks;

results.push({
tmdbId: bookmark.tmdb_id,
meta: bookmark.meta,
group: bookmark.group,
favoriteEpisodes: bookmark.favorite_episodes,
updatedAt: bookmark.updated_at,
});
}
});

if (upserts.length === 0) return [];

return results;
const bookmarks = await prisma.$transaction(upserts);

return bookmarks.map((bookmark: any) => ({
tmdbId: bookmark.tmdb_id,
meta: bookmark.meta,
group: bookmark.group,
favoriteEpisodes: bookmark.favorite_episodes,
updatedAt: bookmark.updated_at,
}));
}


Expand Down
4 changes: 2 additions & 2 deletions server/routes/users/[id]/group-order.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomUUID } from 'crypto';
import { uuidv7 } from 'uuidv7';
import { useAuth } from '~/utils/auth';
import { z } from 'zod';

Expand Down Expand Up @@ -38,7 +38,7 @@ export default defineEventHandler(async event => {
updated_at: new Date(),
},
create: {
id: randomUUID(),
id: uuidv7(),
user_id: userId,
group_order: validatedGroupOrder,
},
Expand Down
26 changes: 25 additions & 1 deletion server/routes/users/[id]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,35 @@ export default defineEventHandler(async event => {
where: { user_id: userId },
});

await tx.watch_history.deleteMany({
where: { user_id: userId },
});

const userLists = await tx.lists.findMany({
where: { user_id: userId },
select: { id: true }
});
const listIds = userLists.map((l: any) => l.id);

if (listIds.length > 0) {
await tx.list_items.deleteMany({
where: { list_id: { in: listIds } },
});
}

await tx.lists.deleteMany({
where: { user_id: userId },
});

await tx.user_group_order.deleteMany({
where: { user_id: userId },
});

await tx.user_settings
.delete({
where: { id: userId },
})
.catch(() => {});
.catch(() => { });

await tx.sessions.deleteMany({
where: { user: userId },
Expand Down
Loading
Loading