diff --git a/.docker/config/config.yml b/.docker/config/config.yml deleted file mode 100644 index 35e2962..0000000 --- a/.docker/config/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -mediaManagers: - - id: "sonarr" - enabled: true - type: "sonarr" - apiUrl: "http://sonarr:8989" - apiKey: "secretsecretsecret99" -discord: - channelId: "1285714063535378522" diff --git a/.docker/sonarr/config.xml b/.docker/sonarr/config.xml deleted file mode 100644 index fa5f6b0..0000000 --- a/.docker/sonarr/config.xml +++ /dev/null @@ -1,17 +0,0 @@ - - * - 8989 - 9898 - False - True - secret - main - debug - - - - Sonarr - Docker - DisabledForLocalAddresses - Basic - \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b0682f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +./config.yml +.docker \ No newline at end of file diff --git a/.gitignore b/.gitignore index b5f70c8..79ba49c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules dist -.docker/postgres +.docker /config.yml .env .env.test diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 2e1f8c5..5883b71 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -10,14 +10,13 @@ services: - NODE_ENV=production - DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres - CONFIG_PATH=/config/config.yml - - DEBUG=true - - SKIP_MIGRATIONS=true + # - DEBUG=true volumes: - - .docker/config:/config + - ./config.yml:/config/config.yml restart: unless-stopped db: - image: postgres:13-alpine + image: postgres:16-alpine environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres @@ -26,18 +25,5 @@ services: - postgres_data:/var/lib/postgresql/data restart: unless-stopped - sonarr: - image: lscr.io/linuxserver/sonarr:latest - container_name: sonarr - environment: - - PUID=1000 - - PGID=1000 - - TZ=Etc/UTC - volumes: - - .docker/sonarr/config.xml:/config/config.xml:rw - ports: - - 8989:8989 - restart: unless-stopped - volumes: postgres_data: diff --git a/src/db/index.ts b/src/db/index.ts index 781c25a..cd38029 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -17,19 +17,21 @@ export const db = drizzle(client, { }, }); -async function main() { +export async function runMigrations() { if (env.NODE_ENV === "production") { - // Run migrations when deployed if (env.SKIP_MIGRATIONS) { + console.log("🙈 Skipping migrations"); return; } const migrationClient = postgres(env.DATABASE_URL); const db = drizzle(migrationClient); - console.log("Running migrations..."); - await migrate(db, { migrationsFolder: "./src/server/db/migrations" }); + console.log("🔨 Running migrations..."); + await migrate(db, { migrationsFolder: "./src/db/migrations" }); await migrationClient.end(); - console.log("Migrations complete!"); + console.log("✅ Migrations complete!"); + } else { + console.log( + "🏃‍♂️ Skipping migrations in dev mode - run manually with 'pnpm db:push'", + ); } } - -void main(); diff --git a/src/db/migrations/0000_dry_sage.sql b/src/db/migrations/0000_dry_sage.sql new file mode 100644 index 0000000..7c39d60 --- /dev/null +++ b/src/db/migrations/0000_dry_sage.sql @@ -0,0 +1,83 @@ +DO $$ BEGIN + CREATE TYPE "public"."manager_type" AS ENUM('radarr', 'sonarr'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."vote_outcome" AS ENUM('keep', 'delete'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "media_items" ( + "id" varchar PRIMARY KEY NOT NULL, + "manager_id" integer NOT NULL, + "manager_type" "manager_type" NOT NULL, + "manager_config_id" varchar NOT NULL, + "title" varchar NOT NULL, + "size_on_disk" numeric DEFAULT '0' NOT NULL, + "release_date" timestamp NOT NULL, + "year" integer, + "has_file" boolean DEFAULT false, + "imdb_id" varchar, + "tmdb_id" integer, + "rating" real, + "added_to_manager" timestamp NOT NULL, + "path_on_disk" varchar, + "image" varchar, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "media_items_manager_id_manager_config_id_unique" UNIQUE("manager_id","manager_config_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "subscribers" ( + "discord_user_id" varchar PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "items_to_delete" ( + "id" varchar PRIMARY KEY NOT NULL, + "delete_after" timestamp NOT NULL, + "deleted_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "voting_sessions" ( + "id" varchar PRIMARY KEY NOT NULL, + "discord_message_id" varchar NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "ends_at" timestamp NOT NULL, + "handled" boolean DEFAULT false NOT NULL, + "vote_outcome" "vote_outcome" +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "whitelist" ( + "id" varchar PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "items_to_delete" ADD CONSTRAINT "items_to_delete_id_media_items_id_fk" FOREIGN KEY ("id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "voting_sessions" ADD CONSTRAINT "voting_sessions_id_media_items_id_fk" FOREIGN KEY ("id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "whitelist" ADD CONSTRAINT "whitelist_id_media_items_id_fk" FOREIGN KEY ("id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "manager_id_idx" ON "media_items" USING btree ("manager_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "manager_type_idx" ON "media_items" USING btree ("manager_type");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "manager_config_id_idx" ON "media_items" USING btree ("manager_config_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "added_to_manager_idx" ON "media_items" USING btree ("added_to_manager");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "discord_message_id_idx" ON "voting_sessions" USING btree ("discord_message_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "handled_idx" ON "voting_sessions" USING btree ("handled"); \ No newline at end of file diff --git a/src/db/migrations/meta/0000_snapshot.json b/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..d4f780a --- /dev/null +++ b/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,423 @@ +{ + "id": "8c9ea751-7eea-48ac-8563-8d43a1e5725f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.media_items": { + "name": "media_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "manager_type": { + "name": "manager_type", + "type": "manager_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "manager_config_id": { + "name": "manager_config_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "release_date": { + "name": "release_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_file": { + "name": "has_file", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "imdb_id": { + "name": "imdb_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "added_to_manager": { + "name": "added_to_manager", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "path_on_disk": { + "name": "path_on_disk", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "manager_id_idx": { + "name": "manager_id_idx", + "columns": [ + { + "expression": "manager_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "manager_type_idx": { + "name": "manager_type_idx", + "columns": [ + { + "expression": "manager_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "manager_config_id_idx": { + "name": "manager_config_id_idx", + "columns": [ + { + "expression": "manager_config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "added_to_manager_idx": { + "name": "added_to_manager_idx", + "columns": [ + { + "expression": "added_to_manager", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_items_manager_id_manager_config_id_unique": { + "name": "media_items_manager_id_manager_config_id_unique", + "nullsNotDistinct": false, + "columns": [ + "manager_id", + "manager_config_id" + ] + } + } + }, + "public.subscribers": { + "name": "subscribers", + "schema": "", + "columns": { + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.items_to_delete": { + "name": "items_to_delete", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "delete_after": { + "name": "delete_after", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "items_to_delete_id_media_items_id_fk": { + "name": "items_to_delete_id_media_items_id_fk", + "tableFrom": "items_to_delete", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.voting_sessions": { + "name": "voting_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "discord_message_id": { + "name": "discord_message_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "handled": { + "name": "handled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "vote_outcome": { + "name": "vote_outcome", + "type": "vote_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "discord_message_id_idx": { + "name": "discord_message_id_idx", + "columns": [ + { + "expression": "discord_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "handled_idx": { + "name": "handled_idx", + "columns": [ + { + "expression": "handled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voting_sessions_id_media_items_id_fk": { + "name": "voting_sessions_id_media_items_id_fk", + "tableFrom": "voting_sessions", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.whitelist": { + "name": "whitelist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "whitelist_id_media_items_id_fk": { + "name": "whitelist_id_media_items_id_fk", + "tableFrom": "whitelist", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.manager_type": { + "name": "manager_type", + "schema": "public", + "values": [ + "radarr", + "sonarr" + ] + }, + "public.vote_outcome": { + "name": "vote_outcome", + "schema": "public", + "values": [ + "keep", + "delete" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..6ec19b4 --- /dev/null +++ b/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1726927500269, + "tag": "0000_dry_sage", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/discord/client.ts b/src/discord/client.ts index cd34d57..26301fe 100644 --- a/src/discord/client.ts +++ b/src/discord/client.ts @@ -28,8 +28,6 @@ discordClient.login(env.DISCORD_BOT_TOKEN); export const discordReadyPromise = new Promise((resolve) => { discordClient.once("ready", () => { - console.log("Discord bot is ready! 🤖"); - console.log("Discord config:", config.discord); resolve(); }); }); diff --git a/src/discord/commands/config.ts b/src/discord/commands/config.ts index 5cd2b4e..725ab1e 100644 --- a/src/discord/commands/config.ts +++ b/src/discord/commands/config.ts @@ -4,7 +4,6 @@ import { config } from "../../config"; import { discordClient } from "../client"; discordClient.once("ready", () => { - console.log("Creating config commands"); discordClient.application?.commands.create({ name: "config", description: "Show the current config", diff --git a/src/discord/commands/deleted.ts b/src/discord/commands/deleted.ts index 00ee1c9..e80e0fc 100644 --- a/src/discord/commands/deleted.ts +++ b/src/discord/commands/deleted.ts @@ -8,7 +8,6 @@ import { itemsToDelete } from "../../db/schema/voting"; import { discordClient } from "../client"; discordClient.once("ready", () => { - console.log("Creating whitelist commands"); discordClient.application?.commands.create({ name: "listdeleted", description: "List the latest deleted items", diff --git a/src/discord/commands/help.ts b/src/discord/commands/help.ts new file mode 100644 index 0000000..c53c0e1 --- /dev/null +++ b/src/discord/commands/help.ts @@ -0,0 +1,34 @@ +import { ApplicationCommandType, CommandInteraction } from "discord.js"; + +import { discordClient } from "../client"; + +discordClient.once("ready", () => { + discordClient.application?.commands.create({ + name: "help", + description: "Get help with the bot", + type: ApplicationCommandType.ChatInput, + }); +}); + +discordClient.on("interactionCreate", async (interaction) => { + if (!interaction.isCommand()) return; + + const { commandName } = interaction; + + if (commandName === "help") { + await handleHelp(interaction); + } +}); + +const helpMessage = ` +Use \`/subscribe\` to subscribe to notifications about new votes. + +Read more at https://github.com/benjick/byedarr +`.trim(); + +async function handleHelp(interaction: CommandInteraction) { + await interaction.reply({ + content: helpMessage, + ephemeral: true, + }); +} diff --git a/src/discord/commands/index.ts b/src/discord/commands/index.ts index af032bf..9681250 100644 --- a/src/discord/commands/index.ts +++ b/src/discord/commands/index.ts @@ -1,4 +1,5 @@ import "./config"; import "./deleted"; +import "./help"; import "./subscribe"; import "./whitelist"; diff --git a/src/discord/commands/subscribe.ts b/src/discord/commands/subscribe.ts index d8f47e3..28ac843 100644 --- a/src/discord/commands/subscribe.ts +++ b/src/discord/commands/subscribe.ts @@ -6,7 +6,6 @@ import { subscribers } from "../../db/schema/subscribers"; import { discordClient } from "../client"; discordClient.once("ready", () => { - console.log("Creating subscribe commands"); discordClient.application?.commands.create({ name: "subscribe", description: "Subscribe to voting notifications for vote candidates", diff --git a/src/discord/commands/whitelist.ts b/src/discord/commands/whitelist.ts index 4b8664b..d9d0664 100644 --- a/src/discord/commands/whitelist.ts +++ b/src/discord/commands/whitelist.ts @@ -6,7 +6,6 @@ import { whitelist } from "../../db/schema/voting"; import { discordClient } from "../client"; discordClient.once("ready", () => { - console.log("Creating whitelist commands"); discordClient.application?.commands.create({ name: "listwhitelist", description: "List the latest whitelisted items", diff --git a/src/env.ts b/src/env.ts index 2da8aef..c516ee6 100644 --- a/src/env.ts +++ b/src/env.ts @@ -16,6 +16,14 @@ const envSchema = z.object({ .default("development"), }); -console.log(process.env); +const envMap = { + DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, + CONFIG_PATH: process.env.CONFIG_PATH, + DATABASE_URL: process.env.DATABASE_URL, + SKIP_MIGRATIONS: process.env.SKIP_MIGRATIONS, + DRY_RUN: process.env.DRY_RUN, + DEBUG: process.env.DEBUG, + NODE_ENV: process.env.NODE_ENV, +}; -export const env = envSchema.parse(process.env); +export const env = envSchema.parse(envMap); diff --git a/src/index.ts b/src/index.ts index e870845..858babd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { schedule } from "node-cron"; import { config } from "./config"; +import { runMigrations } from "./db"; import { discordReadyPromise } from "./discord"; import { env } from "./env"; import { bot } from "./lib/bot"; @@ -9,9 +10,13 @@ import { processDeletions, } from "./services/deletionService"; import { updateDatabase } from "./services/updateService"; -import { processVotingSessions } from "./services/votingService"; +import { + hasExistingVotingSessions, + processVotingSessions, +} from "./services/votingService"; async function main() { + await runMigrations(); console.log(bot); if (env.DEBUG) { console.log("env:", env); @@ -19,6 +24,14 @@ async function main() { } await discordReadyPromise; + const hasVotingSessions = await hasExistingVotingSessions(); + + if (!hasVotingSessions) { + console.log("🤖 First run detected, running initial checks"); + await updateDatabase(); + await determineDeletions(); + } + schedule(config.cron.findMedia, async () => { console.log("⏰ Running cron"); await updateDatabase(); @@ -30,7 +43,6 @@ async function main() { await Promise.all([processVotingSessions(), processDeletions()]); }); - await updateDatabase(); if (env.NODE_ENV === "development") { void dev(); } diff --git a/src/services/votingService.ts b/src/services/votingService.ts index bb1dd11..02847a0 100644 --- a/src/services/votingService.ts +++ b/src/services/votingService.ts @@ -69,6 +69,7 @@ export async function processVotingSessions() { } }); } + function decideOnVotingSession( results: DiscordMessageVotes, ): "keep" | "delete" { @@ -81,3 +82,8 @@ function decideOnVotingSession( } return "delete" as const; } + +export async function hasExistingVotingSessions() { + const res = await db.query.votingSessions.findFirst({}); + return !!res; +}