From 263f9152ec56cd446a5057e1bf0f967920ab05a6 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Sat, 11 Jul 2026 11:46:30 -0600 Subject: [PATCH 1/6] feat: simple plugin download tracking --- .vscode/settings.json | 4 + docker-compose.yml | 36 ++-- package-lock.json | 175 +++++++++++++++--- package.json | 2 + .../plugins/dtos/plugin-ref.dto.ts | 12 ++ .../controllers/plugins/plugins.controller.ts | 24 ++- src/api/decorators/twitch-auth.ts | 11 ++ src/domain/domain.module.ts | 2 + src/domain/plugins/plugin-stats.repository.ts | 9 + src/domain/plugins/plugin-stats.service.ts | 29 +++ src/infrastructure/config.ts | 1 + .../database/database.service.ts | 48 +++++ .../database/plugin-stats.repository.ts | 23 +++ src/infrastructure/infrastructure.module.ts | 8 + 14 files changed, 335 insertions(+), 49 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/api/controllers/plugins/dtos/plugin-ref.dto.ts create mode 100644 src/api/decorators/twitch-auth.ts create mode 100644 src/domain/plugins/plugin-stats.repository.ts create mode 100644 src/domain/plugins/plugin-stats.service.ts create mode 100644 src/infrastructure/database/database.service.ts create mode 100644 src/infrastructure/database/plugin-stats.repository.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fae8e3d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/docker-compose.yml b/docker-compose.yml index 16e2b57..e380943 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,29 +1,21 @@ -version: "3.8" - services: - mongo: - image: mongo:6.0.4-focal - container_name: crowbar-mongo + crowbar-db: + image: postgres:16-alpine + container_name: crowbar-db restart: unless-stopped environment: - MONGO_INITDB_DATABASE: "crowbar" - MONGO_INITDB_ROOT_USERNAME: admin - MONGO_INITDB_ROOT_PASSWORD: crowbartools + POSTGRES_USER: crowbar + POSTGRES_PASSWORD: crowbar + POSTGRES_DB: crowbar ports: - - 127.0.0.1:27017-27019:27017-27019 + - 127.0.0.1:5433:5432 volumes: - - cbDbData:/data/db - - cbDbConfig:/data/configdb - mongo-express: - image: mongo-express - restart: unless-stopped - ports: - - 8081:8081 - environment: - ME_CONFIG_MONGODB_ADMINUSERNAME: admin - ME_CONFIG_MONGODB_ADMINPASSWORD: crowbartools - ME_CONFIG_MONGODB_URL: mongodb://admin:crowbartools@mongo:27017/ + - crowbarDbData:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U crowbar -d crowbar"] + interval: 10s + timeout: 5s + retries: 5 volumes: - cbDbData: - cbDbConfig: + crowbarDbData: diff --git a/package-lock.json b/package-lock.json index c7cb28d..28f3707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "helmet": "^8.1.0", "keyv": "^5.5.0", "node-gzip": "^1.1.2", + "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tar-stream": "^3.2.0", @@ -45,6 +46,7 @@ "@types/jest": "30.0.0", "@types/node": "^22.19.21", "@types/node-gzip": "^1.1.3", + "@types/pg": "^8.20.0", "@types/supertest": "^6.0.3", "@types/tar-stream": "^3.1.4", "@types/ws": "^8.18.1", @@ -2864,6 +2866,18 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -3330,9 +3344,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3347,9 +3358,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3364,9 +3372,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3381,9 +3386,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3398,9 +3400,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3415,9 +3414,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3432,9 +3428,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3449,9 +3442,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8023,6 +8013,95 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8123,6 +8202,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8718,6 +8836,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index a062c61..3c3f746 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "helmet": "^8.1.0", "keyv": "^5.5.0", "node-gzip": "^1.1.2", + "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tar-stream": "^3.2.0", @@ -57,6 +58,7 @@ "@types/jest": "30.0.0", "@types/node": "^22.19.21", "@types/node-gzip": "^1.1.3", + "@types/pg": "^8.20.0", "@types/supertest": "^6.0.3", "@types/tar-stream": "^3.1.4", "@types/ws": "^8.18.1", diff --git a/src/api/controllers/plugins/dtos/plugin-ref.dto.ts b/src/api/controllers/plugins/dtos/plugin-ref.dto.ts new file mode 100644 index 0000000..dfb6e96 --- /dev/null +++ b/src/api/controllers/plugins/dtos/plugin-ref.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString } from "class-validator"; + +export class PluginRefDto { + @ApiProperty() + @IsString() + author: string; + + @ApiProperty() + @IsString() + name: string; +} diff --git a/src/api/controllers/plugins/plugins.controller.ts b/src/api/controllers/plugins/plugins.controller.ts index 3dde735..6905d96 100644 --- a/src/api/controllers/plugins/plugins.controller.ts +++ b/src/api/controllers/plugins/plugins.controller.ts @@ -7,16 +7,24 @@ import { } from "@nestjs/common"; import { ApiResponse } from "@nestjs/swagger"; import { PluginCacheService } from "../../../domain/plugins/plugin-cache.service"; +import { PluginStatsService } from "../../../domain/plugins/plugin-stats.service"; import type { ManifestFirebotVersion, ManagedPluginUpdateRequest } from "@crowbartools/firebot-types"; +import { TwitchAuth } from "src/api/decorators/twitch-auth"; +import { CurrentUser } from "src/api/decorators/current-user.decorator"; +import type { TwitchUser } from "src/domain/profile-data/profile-types"; +import { PluginRefDto } from "./dtos/plugin-ref.dto"; @Controller({ path: "plugins", }) export class PluginsController { - constructor(private readonly pluginCache: PluginCacheService) { } + constructor( + private readonly pluginCache: PluginCacheService, + private readonly pluginStats: PluginStatsService, + ) { } @Get("refresh") @HttpCode(204) @@ -30,7 +38,7 @@ export class PluginsController { @Post("search") @HttpCode(200) async searchPlugins( - @Body() body: { query: string, firebotVersion: ManifestFirebotVersion } + @Body() body: { query: string, firebotVersion: ManifestFirebotVersion }, ) { return await this.pluginCache.searchPlugins(body.query, body.firebotVersion); } @@ -42,4 +50,14 @@ export class PluginsController { ) { return await this.pluginCache.checkPluginsForUpdates(request); } -} \ No newline at end of file + + @Post("track-download") + @HttpCode(200) + @TwitchAuth() + async trackDownload( + @Body() body: PluginRefDto, + @CurrentUser() user: TwitchUser + ) { + return await this.pluginStats.trackDownload(body.author, body.name, user.twitchUserId); + } +} diff --git a/src/api/decorators/twitch-auth.ts b/src/api/decorators/twitch-auth.ts new file mode 100644 index 0000000..28e51fc --- /dev/null +++ b/src/api/decorators/twitch-auth.ts @@ -0,0 +1,11 @@ +import { applyDecorators, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth } from "@nestjs/swagger"; +import { TwitchAuthGuard } from "../auth/twitch-auth.guard"; + +export function TwitchAuth() { + const decorators = [ + UseGuards(TwitchAuthGuard), + ApiBearerAuth("twitchAuth") + ]; + return applyDecorators(...decorators); +} \ No newline at end of file diff --git a/src/domain/domain.module.ts b/src/domain/domain.module.ts index 6704653..a7c60a2 100644 --- a/src/domain/domain.module.ts +++ b/src/domain/domain.module.ts @@ -4,6 +4,7 @@ import { ProfileDataCacheService } from "./profile-data/profile-data-cache.servi import { DataBinCacheService } from "./data-bin/data-bin-cache.service"; import { NotificationCacheService } from "./notifications/notification-cache.service"; import { PluginCacheService } from "./plugins/plugin-cache.service"; +import { PluginStatsService } from "./plugins/plugin-stats.service"; import { SteamService } from "./steam/steam.service"; const providers: Provider[] = [ @@ -11,6 +12,7 @@ const providers: Provider[] = [ DataBinCacheService, NotificationCacheService, PluginCacheService, + PluginStatsService, SteamService, ]; diff --git a/src/domain/plugins/plugin-stats.repository.ts b/src/domain/plugins/plugin-stats.repository.ts new file mode 100644 index 0000000..4746504 --- /dev/null +++ b/src/domain/plugins/plugin-stats.repository.ts @@ -0,0 +1,9 @@ +export type PluginStatTotals = { + author: string; + name: string; + downloads: number; +}; + +export abstract class IPluginStatsRepository { + abstract incrementDownload(author: string, name: string): Promise; +} diff --git a/src/domain/plugins/plugin-stats.service.ts b/src/domain/plugins/plugin-stats.service.ts new file mode 100644 index 0000000..816cc35 --- /dev/null +++ b/src/domain/plugins/plugin-stats.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from "@nestjs/common"; +import { IPluginStatsRepository } from "./plugin-stats.repository"; + +const DOWNLOAD_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 3 days + +@Injectable() +export class PluginStatsService { + constructor(private readonly repository: IPluginStatsRepository) {} + + private readonly downloadCooldowns = new Map(); + + async trackDownload( + author: string, + name: string, + twitchUserId: string, + ): Promise<{ counted: boolean }> { + const key = `${twitchUserId}:${author}/${name}`; + const now = Date.now(); + const last = this.downloadCooldowns.get(key); + + if (last != null && now - last < DOWNLOAD_COOLDOWN_MS) { + return { counted: false }; + } + + await this.repository.incrementDownload(author, name); + this.downloadCooldowns.set(key, now); + return { counted: true }; + } +} diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index 3c0a000..3b71ed7 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -10,4 +10,5 @@ export default registerAs("app", () => ({ cacheLruSize: parseInt(process.env.CACHE_LRU_SIZE!, 10) || 2500, twitchClientId: process.env.TWITCH_CLIENT_ID || "", steamWebApiKey: process.env.STEAM_WEB_API_KEY || "", + databaseUrl: process.env.DATABASE_URL || "", })); diff --git a/src/infrastructure/database/database.service.ts b/src/infrastructure/database/database.service.ts new file mode 100644 index 0000000..f767997 --- /dev/null +++ b/src/infrastructure/database/database.service.ts @@ -0,0 +1,48 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { Pool, QueryResult, QueryResultRow } from "pg"; +import appConfig from "../config"; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS plugin_downloads ( + author text NOT NULL, + name text NOT NULL, + count bigint NOT NULL DEFAULT 0, + PRIMARY KEY (author, name) + ); +`; + +@Injectable() +export class DatabaseService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(DatabaseService.name); + private readonly pool: Pool; + + constructor( + @Inject(appConfig.KEY) + private readonly config: ConfigType, + ) { + this.pool = new Pool({ connectionString: this.config.databaseUrl }); + } + + async onModuleInit(): Promise { + await this.pool.query(SCHEMA); + this.logger.log("Database schema ready"); + } + + async onModuleDestroy(): Promise { + await this.pool.end(); + } + + async query( + text: string, + params?: unknown[], + ): Promise> { + return this.pool.query(text, params); + } +} diff --git a/src/infrastructure/database/plugin-stats.repository.ts b/src/infrastructure/database/plugin-stats.repository.ts new file mode 100644 index 0000000..028cf8d --- /dev/null +++ b/src/infrastructure/database/plugin-stats.repository.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@nestjs/common"; +import { + IPluginStatsRepository, + PluginStatTotals, +} from "src/domain/plugins/plugin-stats.repository"; +import { DatabaseService } from "./database.service"; + +@Injectable() +export class PluginStatsRepository extends IPluginStatsRepository { + constructor(private readonly db: DatabaseService) { + super(); + } + + async incrementDownload(author: string, name: string): Promise { + await this.db.query( + `INSERT INTO plugin_downloads (author, name, count) + VALUES ($1, $2, 1) + ON CONFLICT (author, name) + DO UPDATE SET count = plugin_downloads.count + 1`, + [author, name], + ); + } +} diff --git a/src/infrastructure/infrastructure.module.ts b/src/infrastructure/infrastructure.module.ts index b605b2d..9d6c1ab 100644 --- a/src/infrastructure/infrastructure.module.ts +++ b/src/infrastructure/infrastructure.module.ts @@ -1,13 +1,21 @@ import { forwardRef, Module, Provider, Scope } from "@nestjs/common"; import { ICacheService } from "src/domain/cache/cache-service.interface"; +import { IPluginStatsRepository } from "src/domain/plugins/plugin-stats.repository"; import { DomainModule } from "src/domain/domain.module"; import { CompressedCacheService } from "./cache/compressed-cache.service"; +import { DatabaseService } from "./database/database.service"; +import { PluginStatsRepository } from "./database/plugin-stats.repository"; const providers: Provider[] = [ { provide: ICacheService, useClass: CompressedCacheService, }, + DatabaseService, + { + provide: IPluginStatsRepository, + useClass: PluginStatsRepository, + }, ]; @Module({ From 1cea007b6b07d65c0f4faa19389439f67a82acf7 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Sat, 11 Jul 2026 14:03:41 -0600 Subject: [PATCH 2/6] feat(plugins): support categories, sorting, and pagination in community plugin search --- .../plugins/dtos/firebot-version.dto.ts | 18 ++++ .../plugins/dtos/plugin-search.dto.ts | 55 +++++++++++ .../plugins/dtos/plugin-update-check.dto.ts | 26 ++++++ .../controllers/plugins/plugins.controller.ts | 19 ++-- src/domain/plugins/plugin-cache.service.ts | 93 ++++++++++++++----- src/domain/plugins/plugin-stats.repository.ts | 1 + src/domain/plugins/plugin-stats.service.ts | 5 + src/domain/plugins/plugin-types.ts | 28 +++++- .../database/plugin-stats.repository.ts | 15 +++ 9 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 src/api/controllers/plugins/dtos/firebot-version.dto.ts create mode 100644 src/api/controllers/plugins/dtos/plugin-search.dto.ts create mode 100644 src/api/controllers/plugins/dtos/plugin-update-check.dto.ts diff --git a/src/api/controllers/plugins/dtos/firebot-version.dto.ts b/src/api/controllers/plugins/dtos/firebot-version.dto.ts new file mode 100644 index 0000000..53c593d --- /dev/null +++ b/src/api/controllers/plugins/dtos/firebot-version.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsInt, IsOptional } from "class-validator"; + +export class FirebotVersionDto { + @ApiProperty() + @IsInt() + major: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + minor?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + patch?: number; +} diff --git a/src/api/controllers/plugins/dtos/plugin-search.dto.ts b/src/api/controllers/plugins/dtos/plugin-search.dto.ts new file mode 100644 index 0000000..6456ae1 --- /dev/null +++ b/src/api/controllers/plugins/dtos/plugin-search.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsDefined, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from "class-validator"; +import { + PLUGIN_CATEGORIES, + PLUGIN_SEARCH_SORT_MODES, + type PluginCategory, + type PluginSearchSortMode, +} from "src/domain/plugins/plugin-types"; +import { FirebotVersionDto } from "./firebot-version.dto"; + +export class PluginSearchDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + query?: string; + + @ApiPropertyOptional({ enum: PLUGIN_CATEGORIES }) + @IsOptional() + @IsIn(PLUGIN_CATEGORIES) + category?: PluginCategory; + + @ApiPropertyOptional({ enum: PLUGIN_SEARCH_SORT_MODES, default: "popular" }) + @IsOptional() + @IsIn(PLUGIN_SEARCH_SORT_MODES) + sortBy?: PluginSearchSortMode; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 50, default: 20 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + pageSize?: number; + + @ApiProperty({ type: FirebotVersionDto }) + @IsDefined() + @ValidateNested() + @Type(() => FirebotVersionDto) + firebotVersion: FirebotVersionDto; +} diff --git a/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts b/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts new file mode 100644 index 0000000..dcc8fe7 --- /dev/null +++ b/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsDefined, IsString, ValidateNested } from "class-validator"; +import { FirebotVersionDto } from "./firebot-version.dto"; +import { PluginRefDto } from "./plugin-ref.dto"; + +export class PluginVersionRefDto extends PluginRefDto { + @ApiProperty() + @IsString() + version: string; +} + +export class PluginUpdateCheckDto { + @ApiProperty({ type: [PluginVersionRefDto] }) + @IsDefined() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginVersionRefDto) + plugins: PluginVersionRefDto[]; + + @ApiProperty({ type: FirebotVersionDto }) + @IsDefined() + @ValidateNested() + @Type(() => FirebotVersionDto) + firebotVersion: FirebotVersionDto; +} diff --git a/src/api/controllers/plugins/plugins.controller.ts b/src/api/controllers/plugins/plugins.controller.ts index 6905d96..4a620f2 100644 --- a/src/api/controllers/plugins/plugins.controller.ts +++ b/src/api/controllers/plugins/plugins.controller.ts @@ -8,14 +8,12 @@ import { import { ApiResponse } from "@nestjs/swagger"; import { PluginCacheService } from "../../../domain/plugins/plugin-cache.service"; import { PluginStatsService } from "../../../domain/plugins/plugin-stats.service"; -import type { - ManifestFirebotVersion, - ManagedPluginUpdateRequest -} from "@crowbartools/firebot-types"; import { TwitchAuth } from "src/api/decorators/twitch-auth"; import { CurrentUser } from "src/api/decorators/current-user.decorator"; import type { TwitchUser } from "src/domain/profile-data/profile-types"; import { PluginRefDto } from "./dtos/plugin-ref.dto"; +import { PluginSearchDto } from "./dtos/plugin-search.dto"; +import { PluginUpdateCheckDto } from "./dtos/plugin-update-check.dto"; @Controller({ path: "plugins", @@ -38,15 +36,22 @@ export class PluginsController { @Post("search") @HttpCode(200) async searchPlugins( - @Body() body: { query: string, firebotVersion: ManifestFirebotVersion }, + @Body() body: PluginSearchDto, ) { - return await this.pluginCache.searchPlugins(body.query, body.firebotVersion); + return await this.pluginCache.searchPlugins({ + query: body.query, + category: body.category, + sortBy: body.sortBy ?? "popular", + page: body.page ?? 1, + pageSize: body.pageSize ?? 20, + firebotVersion: body.firebotVersion, + }); } @Post("updates") @HttpCode(200) async checkPluginsForUpdates( - @Body() request: ManagedPluginUpdateRequest + @Body() request: PluginUpdateCheckDto ) { return await this.pluginCache.checkPluginsForUpdates(request); } diff --git a/src/domain/plugins/plugin-cache.service.ts b/src/domain/plugins/plugin-cache.service.ts index 7251318..0057020 100644 --- a/src/domain/plugins/plugin-cache.service.ts +++ b/src/domain/plugins/plugin-cache.service.ts @@ -1,11 +1,14 @@ import type { ManagedPluginManifest, - ManagedPlugin, + ManagedPluginWithManifest, ManagedPluginUpdateRequest, ManifestFirebotVersion, } from "@crowbartools/firebot-types"; import type { CachedPlugin, + CategorizedPluginManifest, + PluginSearchOptions, + PluginSearchSortMode, PluginVersionWithManifest, } from "./plugin-types"; import { Octokit } from "@octokit/rest"; @@ -17,6 +20,7 @@ import { Injectable } from "@nestjs/common"; import { ICacheService } from "../cache/cache-service.interface"; import { createGunzip } from "node:zlib"; import sortVersionStrings from "../util/sort-versions"; +import { PluginStatsService } from "./plugin-stats.service"; const PLUGIN_MANIFEST_OWNER = "crowbartools"; const PLUGIN_MANIFEST_REPO = "firebot-plugins"; @@ -29,7 +33,10 @@ const PLUGIN_MANIFEST_FILENAME_REGEX = /^manifests\/(.*)\/(.*)\/(.*)\/manifest.j @Injectable() export class PluginCacheService { - constructor(private readonly cache: ICacheService) { } + constructor( + private readonly cache: ICacheService, + private readonly pluginStats: PluginStatsService, + ) { } private _octokit = new Octokit(); private _latestHash = ""; @@ -248,37 +255,75 @@ export class PluginCacheService { return null; } - async searchPlugins(query: string, firebotVersion: ManifestFirebotVersion): Promise { + async searchPlugins(options: PluginSearchOptions): Promise<{ items: ManagedPluginWithManifest[], total: number }> { // Load the cache if it hasn't been already await this.loadCache(); const pluginCache = await this.cache.get("plugin-cache") ?? []; - const availablePlugins = pluginCache.map(r => { - const latest = this.getLatestCompatiblePluginVersion(r.versions, firebotVersion); + let plugins = pluginCache.map(r => { + const latest = this.getLatestCompatiblePluginVersion(r.versions, options.firebotVersion); return latest != null ? { author: r.author, name: r.name, version: latest.version, manifest: latest.manifest - } as ManagedPlugin + } as ManagedPluginWithManifest : null; - }).filter(r => r != null) - - const fuse = new Fuse(availablePlugins ?? [], { - keys: [ - "author", - "pluginName", - "manifest.name", - "manifest.author", - "manifest.description", - "manifest.tags" - ], - threshold: 0.3 // We still want fuzzy, but pretty close - }); - const results = fuse.search(query).map(r => r.item) ?? []; - - return results; + }).filter(r => r != null); + + if (options.category != null) { + plugins = plugins.filter(p => + (p.manifest as CategorizedPluginManifest).categories?.includes(options.category!) + ); + } + + const query = options.query?.trim(); + if (query?.length) { + const fuse = new Fuse(plugins, { + keys: [ + "author", + "name", + "manifest.name", + "manifest.author", + "manifest.description", + "manifest.tags" + ], + threshold: 0.3 // We still want fuzzy, but pretty close + }); + plugins = fuse.search(query).map(r => r.item); + } + + const sorted = await this.sortPlugins(plugins, options.sortBy); + + const start = (options.page - 1) * options.pageSize; + return { + items: sorted.slice(start, start + options.pageSize), + total: sorted.length + }; + } + + private async sortPlugins( + plugins: ManagedPluginWithManifest[], + sortBy: PluginSearchSortMode + ): Promise { + switch (sortBy) { + case "popular": { + const downloads = await this.pluginStats.getDownloadTotals(); + return plugins.toSorted((a, b) => + ((downloads.get(`${b.author}/${b.name}`) ?? 0) - (downloads.get(`${a.author}/${a.name}`) ?? 0)) + || a.manifest.name.localeCompare(b.manifest.name) + ); + } + case "recently-updated": + return plugins.toSorted((a, b) => + (Date.parse(b.manifest.releaseDate) || 0) - (Date.parse(a.manifest.releaseDate) || 0) + ); + case "name": + return plugins.toSorted((a, b) => + a.manifest.name.localeCompare(b.manifest.name, undefined, { sensitivity: "base" }) + ); + } } private isLatestVersionNewer(latestVersion: string, currentVersion: string): boolean { @@ -299,12 +344,12 @@ export class PluginCacheService { return true; } - async checkPluginsForUpdates(request: ManagedPluginUpdateRequest): Promise { + async checkPluginsForUpdates(request: ManagedPluginUpdateRequest): Promise { // Load the cache if it hasn't been already await this.loadCache(); const pluginCache = await this.cache.get("plugin-cache") ?? []; - const availableUpdates: ManagedPlugin[] = []; + const availableUpdates: ManagedPluginWithManifest[] = []; for (const currentPlugin of request.plugins) { const plugin = pluginCache.find(p => p.author === currentPlugin.author && p.name === currentPlugin.name); diff --git a/src/domain/plugins/plugin-stats.repository.ts b/src/domain/plugins/plugin-stats.repository.ts index 4746504..04efb4e 100644 --- a/src/domain/plugins/plugin-stats.repository.ts +++ b/src/domain/plugins/plugin-stats.repository.ts @@ -6,4 +6,5 @@ export type PluginStatTotals = { export abstract class IPluginStatsRepository { abstract incrementDownload(author: string, name: string): Promise; + abstract getAllTotals(): Promise; } diff --git a/src/domain/plugins/plugin-stats.service.ts b/src/domain/plugins/plugin-stats.service.ts index 816cc35..26a85ad 100644 --- a/src/domain/plugins/plugin-stats.service.ts +++ b/src/domain/plugins/plugin-stats.service.ts @@ -26,4 +26,9 @@ export class PluginStatsService { this.downloadCooldowns.set(key, now); return { counted: true }; } + + async getDownloadTotals(): Promise> { + const totals = await this.repository.getAllTotals(); + return new Map(totals.map((t) => [`${t.author}/${t.name}`, t.downloads])); + } } diff --git a/src/domain/plugins/plugin-types.ts b/src/domain/plugins/plugin-types.ts index 64d2f77..8a7a361 100644 --- a/src/domain/plugins/plugin-types.ts +++ b/src/domain/plugins/plugin-types.ts @@ -1,4 +1,30 @@ -import type { ManagedPluginManifest } from "@crowbartools/firebot-types"; +import type { ManagedPluginManifest, ManifestFirebotVersion } from "@crowbartools/firebot-types"; + +export const PLUGIN_CATEGORIES = [ + "chat-commands", + "alerts-events", + "overlay-widgets", + "integrations", + "games-fun", + "effects-variables", + "utilities", +] as const; +export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number]; + +export const PLUGIN_SEARCH_SORT_MODES = ["popular", "recently-updated", "name"] as const; +export type PluginSearchSortMode = (typeof PLUGIN_SEARCH_SORT_MODES)[number]; + +// TODO: remove once @crowbartools/firebot-types ships `categories` on ManagedPluginManifest +export type CategorizedPluginManifest = ManagedPluginManifest & { categories?: PluginCategory[] }; + +export type PluginSearchOptions = { + query?: string; + category?: PluginCategory; + sortBy: PluginSearchSortMode; + page: number; + pageSize: number; + firebotVersion: ManifestFirebotVersion; +}; export type PluginVersionWithManifest = { version: string; diff --git a/src/infrastructure/database/plugin-stats.repository.ts b/src/infrastructure/database/plugin-stats.repository.ts index 028cf8d..6fbd69e 100644 --- a/src/infrastructure/database/plugin-stats.repository.ts +++ b/src/infrastructure/database/plugin-stats.repository.ts @@ -20,4 +20,19 @@ export class PluginStatsRepository extends IPluginStatsRepository { [author, name], ); } + + async getAllTotals(): Promise { + const result = await this.db.query<{ + author: string; + name: string; + count: string; + }>("SELECT author, name, count FROM plugin_downloads"); + + // pg returns bigint columns as strings + return result.rows.map((row) => ({ + author: row.author, + name: row.name, + downloads: Number(row.count), + })); + } } From 9d77a8cf36f172855411c46ccfda4777ef69d1a4 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Sat, 11 Jul 2026 14:25:18 -0600 Subject: [PATCH 3/6] feat(plugins): track plugin downloads by version --- .../plugins/dtos/plugin-ref.dto.ts | 4 ++++ .../plugins/dtos/plugin-update-check.dto.ts | 14 ++++------- .../controllers/plugins/plugins.controller.ts | 8 ++++++- src/domain/plugins/plugin-cache.service.ts | 9 +++++++ src/domain/plugins/plugin-stats.repository.ts | 6 ++++- src/domain/plugins/plugin-stats.service.ts | 22 +++++++++-------- .../database/database.service.ts | 3 ++- .../database/plugin-stats.repository.ts | 24 ++++++++++++------- 8 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/api/controllers/plugins/dtos/plugin-ref.dto.ts b/src/api/controllers/plugins/dtos/plugin-ref.dto.ts index dfb6e96..a077044 100644 --- a/src/api/controllers/plugins/dtos/plugin-ref.dto.ts +++ b/src/api/controllers/plugins/dtos/plugin-ref.dto.ts @@ -9,4 +9,8 @@ export class PluginRefDto { @ApiProperty() @IsString() name: string; + + @ApiProperty() + @IsString() + version: string; } diff --git a/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts b/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts index dcc8fe7..f10f1ff 100644 --- a/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts +++ b/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts @@ -1,22 +1,16 @@ import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { IsArray, IsDefined, IsString, ValidateNested } from "class-validator"; +import { IsArray, IsDefined, ValidateNested } from "class-validator"; import { FirebotVersionDto } from "./firebot-version.dto"; import { PluginRefDto } from "./plugin-ref.dto"; -export class PluginVersionRefDto extends PluginRefDto { - @ApiProperty() - @IsString() - version: string; -} - export class PluginUpdateCheckDto { - @ApiProperty({ type: [PluginVersionRefDto] }) + @ApiProperty({ type: [PluginRefDto] }) @IsDefined() @IsArray() @ValidateNested({ each: true }) - @Type(() => PluginVersionRefDto) - plugins: PluginVersionRefDto[]; + @Type(() => PluginRefDto) + plugins: PluginRefDto[]; @ApiProperty({ type: FirebotVersionDto }) @IsDefined() diff --git a/src/api/controllers/plugins/plugins.controller.ts b/src/api/controllers/plugins/plugins.controller.ts index 4a620f2..a7aacb7 100644 --- a/src/api/controllers/plugins/plugins.controller.ts +++ b/src/api/controllers/plugins/plugins.controller.ts @@ -4,6 +4,7 @@ import { Post, HttpCode, Body, + NotFoundException, } from "@nestjs/common"; import { ApiResponse } from "@nestjs/swagger"; import { PluginCacheService } from "../../../domain/plugins/plugin-cache.service"; @@ -63,6 +64,11 @@ export class PluginsController { @Body() body: PluginRefDto, @CurrentUser() user: TwitchUser ) { - return await this.pluginStats.trackDownload(body.author, body.name, user.twitchUserId); + const validPluginVersion = await this.pluginCache.pluginVersionExists(body.author, body.name, body.version); + if (!validPluginVersion) { + throw new NotFoundException("Unknown plugin version"); + } + + return await this.pluginStats.trackDownload(body.author, body.name, body.version, user.twitchUserId); } } diff --git a/src/domain/plugins/plugin-cache.service.ts b/src/domain/plugins/plugin-cache.service.ts index 0057020..7509831 100644 --- a/src/domain/plugins/plugin-cache.service.ts +++ b/src/domain/plugins/plugin-cache.service.ts @@ -255,6 +255,15 @@ export class PluginCacheService { return null; } + async pluginVersionExists(author: string, name: string, version: string): Promise { + // Load the cache if it hasn't been already + await this.loadCache(); + + const pluginCache = await this.cache.get("plugin-cache") ?? []; + const plugin = pluginCache.find(p => p.author === author && p.name === name); + return plugin?.versions.some(v => v.version === version) ?? false; + } + async searchPlugins(options: PluginSearchOptions): Promise<{ items: ManagedPluginWithManifest[], total: number }> { // Load the cache if it hasn't been already await this.loadCache(); diff --git a/src/domain/plugins/plugin-stats.repository.ts b/src/domain/plugins/plugin-stats.repository.ts index 04efb4e..ae50e44 100644 --- a/src/domain/plugins/plugin-stats.repository.ts +++ b/src/domain/plugins/plugin-stats.repository.ts @@ -5,6 +5,10 @@ export type PluginStatTotals = { }; export abstract class IPluginStatsRepository { - abstract incrementDownload(author: string, name: string): Promise; + abstract incrementDownload( + author: string, + name: string, + version: string, + ): Promise; abstract getAllTotals(): Promise; } diff --git a/src/domain/plugins/plugin-stats.service.ts b/src/domain/plugins/plugin-stats.service.ts index 26a85ad..c57044d 100644 --- a/src/domain/plugins/plugin-stats.service.ts +++ b/src/domain/plugins/plugin-stats.service.ts @@ -1,29 +1,31 @@ -import { Injectable } from "@nestjs/common"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable } from "@nestjs/common"; +import { Cache } from "cache-manager"; import { IPluginStatsRepository } from "./plugin-stats.repository"; const DOWNLOAD_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 3 days @Injectable() export class PluginStatsService { - constructor(private readonly repository: IPluginStatsRepository) {} - - private readonly downloadCooldowns = new Map(); + constructor( + private readonly repository: IPluginStatsRepository, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} async trackDownload( author: string, name: string, + version: string, twitchUserId: string, ): Promise<{ counted: boolean }> { - const key = `${twitchUserId}:${author}/${name}`; - const now = Date.now(); - const last = this.downloadCooldowns.get(key); + const cooldownKey = `plugin-download-cooldown:${twitchUserId}:${author}/${name}@${version}`; - if (last != null && now - last < DOWNLOAD_COOLDOWN_MS) { + if (await this.cacheManager.get(cooldownKey)) { return { counted: false }; } - await this.repository.incrementDownload(author, name); - this.downloadCooldowns.set(key, now); + await this.repository.incrementDownload(author, name, version); + await this.cacheManager.set(cooldownKey, true, DOWNLOAD_COOLDOWN_MS); return { counted: true }; } diff --git a/src/infrastructure/database/database.service.ts b/src/infrastructure/database/database.service.ts index f767997..4300541 100644 --- a/src/infrastructure/database/database.service.ts +++ b/src/infrastructure/database/database.service.ts @@ -13,8 +13,9 @@ const SCHEMA = ` CREATE TABLE IF NOT EXISTS plugin_downloads ( author text NOT NULL, name text NOT NULL, + version text NOT NULL, count bigint NOT NULL DEFAULT 0, - PRIMARY KEY (author, name) + PRIMARY KEY (author, name, version) ); `; diff --git a/src/infrastructure/database/plugin-stats.repository.ts b/src/infrastructure/database/plugin-stats.repository.ts index 6fbd69e..7f3f82e 100644 --- a/src/infrastructure/database/plugin-stats.repository.ts +++ b/src/infrastructure/database/plugin-stats.repository.ts @@ -11,13 +11,17 @@ export class PluginStatsRepository extends IPluginStatsRepository { super(); } - async incrementDownload(author: string, name: string): Promise { + async incrementDownload( + author: string, + name: string, + version: string, + ): Promise { await this.db.query( - `INSERT INTO plugin_downloads (author, name, count) - VALUES ($1, $2, 1) - ON CONFLICT (author, name) + `INSERT INTO plugin_downloads (author, name, version, count) + VALUES ($1, $2, $3, 1) + ON CONFLICT (author, name, version) DO UPDATE SET count = plugin_downloads.count + 1`, - [author, name], + [author, name, version], ); } @@ -25,14 +29,18 @@ export class PluginStatsRepository extends IPluginStatsRepository { const result = await this.db.query<{ author: string; name: string; - count: string; - }>("SELECT author, name, count FROM plugin_downloads"); + total: string; + }>( + `SELECT author, name, SUM(count) AS total + FROM plugin_downloads + GROUP BY author, name`, + ); // pg returns bigint columns as strings return result.rows.map((row) => ({ author: row.author, name: row.name, - downloads: Number(row.count), + downloads: Number(row.total), })); } } From 5ba682ead6b7ec74315d8d656e637aca9e2120fc Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Sat, 11 Jul 2026 21:48:57 -0600 Subject: [PATCH 4/6] feat(plugins): support ability to filter plugins by features --- package-lock.json | 8 +++--- package.json | 2 +- .../plugins/dtos/plugin-search.dto.ts | 11 +++++++- .../controllers/plugins/plugins.controller.ts | 7 +++-- src/domain/plugins/plugin-cache.service.ts | 23 +++++++++------ src/domain/plugins/plugin-types.ts | 28 +++++++++++++------ .../database/plugin-stats.repository.ts | 2 +- src/infrastructure/infrastructure.module.ts | 6 ++-- 8 files changed, 56 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28f3707..f36493f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "ws": "^8.18.3" }, "devDependencies": { - "@crowbartools/firebot-types": "^5.67.0-alpha27", + "@crowbartools/firebot-types": "^5.67.0-alpha29", "@nestjs/cli": "^11.0.10", "@nestjs/schematics": "^11.0.7", "@nestjs/testing": "^11.1.6", @@ -732,9 +732,9 @@ } }, "node_modules/@crowbartools/firebot-types": { - "version": "5.67.0-alpha27", - "resolved": "https://registry.npmjs.org/@crowbartools/firebot-types/-/firebot-types-5.67.0-alpha27.tgz", - "integrity": "sha512-rtRUXFoe4+lmoq1VYSkhKehEK+cERgyRkwwZGaGHVTMIjODE+xr88+xecIEmOjtKNOiPnUbHwF+UYIMSH716xw==", + "version": "5.67.0-alpha29", + "resolved": "https://registry.npmjs.org/@crowbartools/firebot-types/-/firebot-types-5.67.0-alpha29.tgz", + "integrity": "sha512-Cq6WPCkkFI5pHGHRXxisurg3lLF3GpPTcXZJ98oxLV3x0gFMF6VsWjyDg1hP3Kjt0flOYaokoiKCPg6WrJeYdg==", "dev": true, "license": "GPL-3.0", "peerDependencies": { diff --git a/package.json b/package.json index 3c3f746..0dd568f 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "ws": "^8.18.3" }, "devDependencies": { - "@crowbartools/firebot-types": "^5.67.0-alpha27", + "@crowbartools/firebot-types": "^5.67.0-alpha29", "@nestjs/cli": "^11.0.10", "@nestjs/schematics": "^11.0.7", "@nestjs/testing": "^11.1.6", diff --git a/src/api/controllers/plugins/dtos/plugin-search.dto.ts b/src/api/controllers/plugins/dtos/plugin-search.dto.ts index 6456ae1..8e10965 100644 --- a/src/api/controllers/plugins/dtos/plugin-search.dto.ts +++ b/src/api/controllers/plugins/dtos/plugin-search.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { + IsArray, IsDefined, IsIn, IsInt, @@ -12,10 +13,12 @@ import { } from "class-validator"; import { PLUGIN_CATEGORIES, + PLUGIN_FEATURES, PLUGIN_SEARCH_SORT_MODES, type PluginCategory, + type PluginFeature, type PluginSearchSortMode, -} from "src/domain/plugins/plugin-types"; +} from "../../../../domain/plugins/plugin-types"; import { FirebotVersionDto } from "./firebot-version.dto"; export class PluginSearchDto { @@ -29,6 +32,12 @@ export class PluginSearchDto { @IsIn(PLUGIN_CATEGORIES) category?: PluginCategory; + @ApiPropertyOptional({ enum: PLUGIN_FEATURES, isArray: true }) + @IsOptional() + @IsArray() + @IsIn(PLUGIN_FEATURES, { each: true }) + features?: PluginFeature[]; + @ApiPropertyOptional({ enum: PLUGIN_SEARCH_SORT_MODES, default: "popular" }) @IsOptional() @IsIn(PLUGIN_SEARCH_SORT_MODES) diff --git a/src/api/controllers/plugins/plugins.controller.ts b/src/api/controllers/plugins/plugins.controller.ts index a7aacb7..67313f3 100644 --- a/src/api/controllers/plugins/plugins.controller.ts +++ b/src/api/controllers/plugins/plugins.controller.ts @@ -9,9 +9,9 @@ import { import { ApiResponse } from "@nestjs/swagger"; import { PluginCacheService } from "../../../domain/plugins/plugin-cache.service"; import { PluginStatsService } from "../../../domain/plugins/plugin-stats.service"; -import { TwitchAuth } from "src/api/decorators/twitch-auth"; -import { CurrentUser } from "src/api/decorators/current-user.decorator"; -import type { TwitchUser } from "src/domain/profile-data/profile-types"; +import { TwitchAuth } from "../../decorators/twitch-auth"; +import { CurrentUser } from "../../decorators/current-user.decorator"; +import type { TwitchUser } from "../../../domain/profile-data/profile-types"; import { PluginRefDto } from "./dtos/plugin-ref.dto"; import { PluginSearchDto } from "./dtos/plugin-search.dto"; import { PluginUpdateCheckDto } from "./dtos/plugin-update-check.dto"; @@ -42,6 +42,7 @@ export class PluginsController { return await this.pluginCache.searchPlugins({ query: body.query, category: body.category, + features: body.features, sortBy: body.sortBy ?? "popular", page: body.page ?? 1, pageSize: body.pageSize ?? 20, diff --git a/src/domain/plugins/plugin-cache.service.ts b/src/domain/plugins/plugin-cache.service.ts index 7509831..b433236 100644 --- a/src/domain/plugins/plugin-cache.service.ts +++ b/src/domain/plugins/plugin-cache.service.ts @@ -1,12 +1,11 @@ import type { + ManagedPlugin, ManagedPluginManifest, - ManagedPluginWithManifest, ManagedPluginUpdateRequest, ManifestFirebotVersion, } from "@crowbartools/firebot-types"; import type { CachedPlugin, - CategorizedPluginManifest, PluginSearchOptions, PluginSearchSortMode, PluginVersionWithManifest, @@ -264,7 +263,7 @@ export class PluginCacheService { return plugin?.versions.some(v => v.version === version) ?? false; } - async searchPlugins(options: PluginSearchOptions): Promise<{ items: ManagedPluginWithManifest[], total: number }> { + async searchPlugins(options: PluginSearchOptions): Promise<{ items: ManagedPlugin[], total: number }> { // Load the cache if it hasn't been already await this.loadCache(); @@ -277,13 +276,19 @@ export class PluginCacheService { name: r.name, version: latest.version, manifest: latest.manifest - } as ManagedPluginWithManifest + } : null; }).filter(r => r != null); if (options.category != null) { plugins = plugins.filter(p => - (p.manifest as CategorizedPluginManifest).categories?.includes(options.category!) + (p.manifest).category === options.category + ); + } + + if (options.features?.length) { + plugins = plugins.filter(p => + p.manifest.features?.some(f => options.features!.includes(f)) ); } @@ -313,9 +318,9 @@ export class PluginCacheService { } private async sortPlugins( - plugins: ManagedPluginWithManifest[], + plugins: ManagedPlugin[], sortBy: PluginSearchSortMode - ): Promise { + ): Promise { switch (sortBy) { case "popular": { const downloads = await this.pluginStats.getDownloadTotals(); @@ -353,12 +358,12 @@ export class PluginCacheService { return true; } - async checkPluginsForUpdates(request: ManagedPluginUpdateRequest): Promise { + async checkPluginsForUpdates(request: ManagedPluginUpdateRequest): Promise { // Load the cache if it hasn't been already await this.loadCache(); const pluginCache = await this.cache.get("plugin-cache") ?? []; - const availableUpdates: ManagedPluginWithManifest[] = []; + const availableUpdates: ManagedPlugin[] = []; for (const currentPlugin of request.plugins) { const plugin = pluginCache.find(p => p.author === currentPlugin.author && p.name === currentPlugin.name); diff --git a/src/domain/plugins/plugin-types.ts b/src/domain/plugins/plugin-types.ts index 8a7a361..c3dbb46 100644 --- a/src/domain/plugins/plugin-types.ts +++ b/src/domain/plugins/plugin-types.ts @@ -1,25 +1,35 @@ import type { ManagedPluginManifest, ManifestFirebotVersion } from "@crowbartools/firebot-types"; export const PLUGIN_CATEGORIES = [ - "chat-commands", - "alerts-events", - "overlay-widgets", - "integrations", - "games-fun", - "effects-variables", - "utilities", + "stream-services", + "social", + "music-media", + "games", + "overlays", + "tools-utilities", ] as const; export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number]; +export const PLUGIN_FEATURES = [ + "effects", + "events", + "variables", + "integrations", + "overlay-widgets", + "games", + "commands", + "ui-extensions", +] as const; +export type PluginFeature = (typeof PLUGIN_FEATURES)[number]; + export const PLUGIN_SEARCH_SORT_MODES = ["popular", "recently-updated", "name"] as const; export type PluginSearchSortMode = (typeof PLUGIN_SEARCH_SORT_MODES)[number]; -// TODO: remove once @crowbartools/firebot-types ships `categories` on ManagedPluginManifest -export type CategorizedPluginManifest = ManagedPluginManifest & { categories?: PluginCategory[] }; export type PluginSearchOptions = { query?: string; category?: PluginCategory; + features?: PluginFeature[]; sortBy: PluginSearchSortMode; page: number; pageSize: number; diff --git a/src/infrastructure/database/plugin-stats.repository.ts b/src/infrastructure/database/plugin-stats.repository.ts index 7f3f82e..1c29aa8 100644 --- a/src/infrastructure/database/plugin-stats.repository.ts +++ b/src/infrastructure/database/plugin-stats.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common"; import { IPluginStatsRepository, PluginStatTotals, -} from "src/domain/plugins/plugin-stats.repository"; +} from "../../domain/plugins/plugin-stats.repository"; import { DatabaseService } from "./database.service"; @Injectable() diff --git a/src/infrastructure/infrastructure.module.ts b/src/infrastructure/infrastructure.module.ts index 9d6c1ab..1bde086 100644 --- a/src/infrastructure/infrastructure.module.ts +++ b/src/infrastructure/infrastructure.module.ts @@ -1,7 +1,7 @@ import { forwardRef, Module, Provider, Scope } from "@nestjs/common"; -import { ICacheService } from "src/domain/cache/cache-service.interface"; -import { IPluginStatsRepository } from "src/domain/plugins/plugin-stats.repository"; -import { DomainModule } from "src/domain/domain.module"; +import { ICacheService } from "../domain/cache/cache-service.interface"; +import { IPluginStatsRepository } from "../domain/plugins/plugin-stats.repository"; +import { DomainModule } from "../domain/domain.module"; import { CompressedCacheService } from "./cache/compressed-cache.service"; import { DatabaseService } from "./database/database.service"; import { PluginStatsRepository } from "./database/plugin-stats.repository"; From 02b456bf2f29ed52ced4b4ff3e5a785f99f2077d Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Sat, 11 Jul 2026 21:51:06 -0600 Subject: [PATCH 5/6] chore: add back ManagedPlugin type cast --- src/domain/plugins/plugin-cache.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/plugins/plugin-cache.service.ts b/src/domain/plugins/plugin-cache.service.ts index b433236..71e0503 100644 --- a/src/domain/plugins/plugin-cache.service.ts +++ b/src/domain/plugins/plugin-cache.service.ts @@ -276,7 +276,7 @@ export class PluginCacheService { name: r.name, version: latest.version, manifest: latest.manifest - } + } as ManagedPlugin : null; }).filter(r => r != null); From c3d3a2f6be061b33558db5685d9f61520b1e13c7 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Sun, 12 Jul 2026 09:58:32 -0600 Subject: [PATCH 6/6] feat(plugins): add support for "Official" (first-party) plugins --- .../plugins/dtos/plugin-search.dto.ts | 6 +++++ .../controllers/plugins/plugins.controller.ts | 5 ++-- src/domain/plugins/plugin-cache.service.ts | 23 +++++++++++++++---- src/domain/plugins/plugin-stats.service.ts | 6 ++--- src/domain/plugins/plugin-types.ts | 12 ++++++++++ 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/api/controllers/plugins/dtos/plugin-search.dto.ts b/src/api/controllers/plugins/dtos/plugin-search.dto.ts index 8e10965..b93db87 100644 --- a/src/api/controllers/plugins/dtos/plugin-search.dto.ts +++ b/src/api/controllers/plugins/dtos/plugin-search.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsArray, + IsBoolean, IsDefined, IsIn, IsInt, @@ -38,6 +39,11 @@ export class PluginSearchDto { @IsIn(PLUGIN_FEATURES, { each: true }) features?: PluginFeature[]; + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + official?: boolean; + @ApiPropertyOptional({ enum: PLUGIN_SEARCH_SORT_MODES, default: "popular" }) @IsOptional() @IsIn(PLUGIN_SEARCH_SORT_MODES) diff --git a/src/api/controllers/plugins/plugins.controller.ts b/src/api/controllers/plugins/plugins.controller.ts index 67313f3..50848bf 100644 --- a/src/api/controllers/plugins/plugins.controller.ts +++ b/src/api/controllers/plugins/plugins.controller.ts @@ -43,6 +43,7 @@ export class PluginsController { query: body.query, category: body.category, features: body.features, + official: body.official, sortBy: body.sortBy ?? "popular", page: body.page ?? 1, pageSize: body.pageSize ?? 20, @@ -67,9 +68,9 @@ export class PluginsController { ) { const validPluginVersion = await this.pluginCache.pluginVersionExists(body.author, body.name, body.version); if (!validPluginVersion) { - throw new NotFoundException("Unknown plugin version"); + return; } - return await this.pluginStats.trackDownload(body.author, body.name, body.version, user.twitchUserId); + await this.pluginStats.trackDownload(body.author, body.name, body.version, user.twitchUserId); } } diff --git a/src/domain/plugins/plugin-cache.service.ts b/src/domain/plugins/plugin-cache.service.ts index 71e0503..340f6ac 100644 --- a/src/domain/plugins/plugin-cache.service.ts +++ b/src/domain/plugins/plugin-cache.service.ts @@ -4,11 +4,12 @@ import type { ManagedPluginUpdateRequest, ManifestFirebotVersion, } from "@crowbartools/firebot-types"; -import type { - CachedPlugin, - PluginSearchOptions, - PluginSearchSortMode, - PluginVersionWithManifest, +import { + OFFICIAL_PLUGIN_GITHUB_ORGS, + type CachedPlugin, + type PluginSearchOptions, + type PluginSearchSortMode, + type PluginVersionWithManifest, } from "./plugin-types"; import { Octokit } from "@octokit/rest"; import { Readable } from "node:stream"; @@ -254,6 +255,14 @@ export class PluginCacheService { return null; } + private isOfficialPlugin(manifest: ManagedPluginManifest): boolean { + const repoOwner = manifest.repo + ?.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\//i)?.[1] + ?.toLowerCase(); + + return repoOwner != null && (OFFICIAL_PLUGIN_GITHUB_ORGS as readonly string[]).includes(repoOwner); + } + async pluginVersionExists(author: string, name: string, version: string): Promise { // Load the cache if it hasn't been already await this.loadCache(); @@ -292,6 +301,10 @@ export class PluginCacheService { ); } + if (options.official === true) { + plugins = plugins.filter(p => this.isOfficialPlugin(p.manifest)); + } + const query = options.query?.trim(); if (query?.length) { const fuse = new Fuse(plugins, { diff --git a/src/domain/plugins/plugin-stats.service.ts b/src/domain/plugins/plugin-stats.service.ts index c57044d..ed28b7f 100644 --- a/src/domain/plugins/plugin-stats.service.ts +++ b/src/domain/plugins/plugin-stats.service.ts @@ -17,16 +17,16 @@ export class PluginStatsService { name: string, version: string, twitchUserId: string, - ): Promise<{ counted: boolean }> { + ): Promise { const cooldownKey = `plugin-download-cooldown:${twitchUserId}:${author}/${name}@${version}`; if (await this.cacheManager.get(cooldownKey)) { - return { counted: false }; + return; } await this.repository.incrementDownload(author, name, version); await this.cacheManager.set(cooldownKey, true, DOWNLOAD_COOLDOWN_MS); - return { counted: true }; + return; } async getDownloadTotals(): Promise> { diff --git a/src/domain/plugins/plugin-types.ts b/src/domain/plugins/plugin-types.ts index c3dbb46..229977d 100644 --- a/src/domain/plugins/plugin-types.ts +++ b/src/domain/plugins/plugin-types.ts @@ -25,11 +25,23 @@ export type PluginFeature = (typeof PLUGIN_FEATURES)[number]; export const PLUGIN_SEARCH_SORT_MODES = ["popular", "recently-updated", "name"] as const; export type PluginSearchSortMode = (typeof PLUGIN_SEARCH_SORT_MODES)[number]; +export const OFFICIAL_PLUGIN_GITHUB_ORGS = [ + "crowbartools", + "ebiggz", + "zunderscore", + "sreject", + "heyaapl", + "cavemobster", + "itsjesski", + "brumoen", +] as const; + export type PluginSearchOptions = { query?: string; category?: PluginCategory; features?: PluginFeature[]; + official?: boolean; sortBy: PluginSearchSortMode; page: number; pageSize: number;