diff --git a/apps/backend/drizzle.config.ts b/apps/backend/drizzle.config.ts new file mode 100644 index 0000000..d2f20c9 --- /dev/null +++ b/apps/backend/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'sqlite', + schema: './src/database/schema.ts', + out: './drizzle', +}) diff --git a/apps/backend/drizzle/0000_mysterious_vin_gonzales.sql b/apps/backend/drizzle/0000_mysterious_vin_gonzales.sql new file mode 100644 index 0000000..6480af7 --- /dev/null +++ b/apps/backend/drizzle/0000_mysterious_vin_gonzales.sql @@ -0,0 +1,26 @@ +CREATE TABLE `downloads` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `torrent_filename` text NOT NULL, + `peer_id` text NOT NULL, + `peer_name` text NOT NULL, + `item_id` text NOT NULL, + `filename` text NOT NULL, + `dest_path` text NOT NULL, + `part_path` text NOT NULL, + `release_size` integer NOT NULL, + `release_json` text NOT NULL, + `expected_bytes` integer, + `expected_bytes_source` text, + `expected_bytes_mismatch` integer DEFAULT false NOT NULL, + `downloaded_bytes` integer DEFAULT 0 NOT NULL, + `status` text NOT NULL, + `started_at` text NOT NULL, + `updated_at` text NOT NULL, + `completed_at` text, + `error` text, + CONSTRAINT "downloads_status_check" CHECK("downloads"."status" in ('downloading', 'completed', 'failed', 'import_queued')), + CONSTRAINT "downloads_expected_bytes_source_check" CHECK("downloads"."expected_bytes_source" is null or "downloads"."expected_bytes_source" = 'content_length') +); +--> statement-breakpoint +CREATE INDEX `downloads_status_idx` ON `downloads` (`status`);--> statement-breakpoint +CREATE INDEX `downloads_updated_at_idx` ON `downloads` (`updated_at`); \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..109173c --- /dev/null +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,187 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7511a5bb-12b1-4836-8099-498542b3d20d", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "downloads": { + "name": "downloads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "torrent_filename": { + "name": "torrent_filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_name": { + "name": "peer_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_path": { + "name": "part_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_size": { + "name": "release_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_json": { + "name": "release_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_bytes": { + "name": "expected_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_source": { + "name": "expected_bytes_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_mismatch": { + "name": "expected_bytes_mismatch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "downloaded_bytes": { + "name": "downloaded_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "downloads_status_idx": { + "name": "downloads_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloads_updated_at_idx": { + "name": "downloads_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "downloads_status_check": { + "name": "downloads_status_check", + "value": "\"downloads\".\"status\" in ('downloading', 'completed', 'failed', 'import_queued')" + }, + "downloads_expected_bytes_source_check": { + "name": "downloads_expected_bytes_source_check", + "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" = 'content_length'" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..35efc42 --- /dev/null +++ b/apps/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1780589033291, + "tag": "0000_mysterious_vin_gonzales", + "breakpoints": true + } + ] +} diff --git a/apps/backend/package.json b/apps/backend/package.json index 4992910..9ea785c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,6 +19,7 @@ "@opentelemetry/sdk-node": "0.218.0", "@opentelemetry/sdk-trace-base": "2.7.1", "bencode": "^4.0.0", + "drizzle-orm": "^0.45.2", "hono": "4.12.8", "hono-openapi": "1.3.0", "jsonc": "2.0.0", @@ -29,6 +30,7 @@ "@antfu/eslint-config": "^7.7.3", "@total-typescript/ts-reset": "0.6.1", "@types/bun": "1.3.11", + "drizzle-kit": "^0.31.10", "eslint": "10.1.0", "msw": "^2.12.14", "pino-pretty": "^13.1.3" diff --git a/apps/backend/src/__tests__/database.test.ts b/apps/backend/src/__tests__/database.test.ts new file mode 100644 index 0000000..82c618a --- /dev/null +++ b/apps/backend/src/__tests__/database.test.ts @@ -0,0 +1,154 @@ +import type { Release } from '../lib/release' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { getDatabasePath, openDatabase } from '../database/connection' +import { DownloadsRepository } from '../modules/downloads/downloads.repository' + +let tempDir: string + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'jack-database-')) +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +describe('database connection', () => { + test('places database.sqlite next to config.jsonc', () => { + expect(getDatabasePath('/config/config.jsonc')).toBe('/config/database.sqlite') + expect(getDatabasePath(join(tempDir, 'config.jsonc'))).toBe(join(tempDir, 'database.sqlite')) + }) + + test('creates the downloads table and persists data across reopen', async () => { + const configPath = join(tempDir, 'config.jsonc') + const first = await openDatabase({ appConfigPath: configPath }) + first.sqlite.exec(` + insert into downloads ( + torrent_filename, peer_id, peer_name, item_id, filename, dest_path, part_path, + release_size, release_json, downloaded_bytes, status, started_at, updated_at + ) values ( + 'movie.torrent', 'peer-1', 'Friend Jack', 'movie:1', 'Movie.mkv', + '/complete/Movie.mkv', '/complete/Movie.mkv.part', 10, '{}', 0, + 'downloading', '2026-06-04T00:00:00.000Z', '2026-06-04T00:00:00.000Z' + ) + `) + first.close() + + const second = await openDatabase({ appConfigPath: configPath }) + const row = second.sqlite.query('select torrent_filename from downloads').get() as { torrent_filename: string } + expect(row.torrent_filename).toBe('movie.torrent') + second.close() + }) +}) + +const release: Release = { + id: 'remote:movie:1', + title: 'Movie.2024.1080p', + filename: 'Movie.2024.1080p.mkv', + category: 2000, + size: 100, + imdbId: 'tt1234567', + tmdbId: 123, + quality: { name: 'Bluray-1080p', source: 'bluray', resolution: 1080 }, +} + +describe('DownloadsRepository', () => { + test('creates rows after release metadata is available and lists newest first', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + + const first = repository.create({ + torrentFilename: 'first.torrent', + peerId: 'peer-1', + peerName: 'Friend Jack', + itemId: 'movie:1', + filename: release.filename, + destPath: join(tempDir, release.filename), + partPath: join(tempDir, `${release.filename}.part`), + releaseSize: release.size, + release, + }) + + const second = repository.create({ + torrentFilename: 'second.torrent', + peerId: 'peer-1', + peerName: 'Friend Jack', + itemId: 'movie:2', + filename: 'Second.mkv', + destPath: join(tempDir, 'Second.mkv'), + partPath: join(tempDir, 'Second.mkv.part'), + releaseSize: 200, + release: { ...release, id: 'remote:movie:2', filename: 'Second.mkv', size: 200 }, + }) + + expect(first.status).toBe('downloading') + expect(repository.list()[0]?.id).toBe(second.id) + expect(repository.get(first.id)?.release.title).toBe('Movie.2024.1080p') + handle.close() + }) + + test('updates expected bytes, progress, completion, import queue, and failure', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const created = repository.create({ + torrentFilename: 'movie.torrent', + peerId: 'peer-1', + peerName: 'Friend Jack', + itemId: 'movie:1', + filename: release.filename, + destPath: join(tempDir, release.filename), + partPath: join(tempDir, `${release.filename}.part`), + releaseSize: release.size, + release, + }) + + repository.setExpectedBytes(created.id, 120, 'content_length', true) + repository.updateProgress(created.id, 40) + repository.markCompleted(created.id, 120) + repository.markImportQueued(created.id) + + const done = repository.get(created.id)! + expect(done.expectedBytes).toBe(120) + expect(done.expectedBytesSource).toBe('content_length') + expect(done.expectedBytesMismatch).toBe(true) + expect(done.downloadedBytes).toBe(120) + expect(done.status).toBe('import_queued') + expect(typeof done.completedAt).toBe('string') + + repository.markFailed(created.id, 'import failed after queue') + const failed = repository.get(created.id)! + expect(failed.status).toBe('failed') + expect(failed.error).toBe('import failed after queue') + handle.close() + }) + + test('reconciles stale downloading rows using .part file size', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const partPath = join(tempDir, 'Movie.mkv.part') + await writeFile(partPath, new Uint8Array([1, 2, 3, 4])) + + const created = repository.create({ + torrentFilename: 'movie.torrent', + peerId: 'peer-1', + peerName: 'Friend Jack', + itemId: 'movie:1', + filename: release.filename, + destPath: join(tempDir, release.filename), + partPath, + releaseSize: release.size, + release, + }) + + const reconciled = await repository.reconcileStaleDownloads() + const stale = repository.get(created.id)! + expect(reconciled).toBe(1) + expect(stale.status).toBe('failed') + expect(stale.downloadedBytes).toBe(4) + expect(stale.error).toContain('stale') + handle.close() + }) +}) diff --git a/apps/backend/src/__tests__/downloads-api.test.ts b/apps/backend/src/__tests__/downloads-api.test.ts new file mode 100644 index 0000000..09d987c --- /dev/null +++ b/apps/backend/src/__tests__/downloads-api.test.ts @@ -0,0 +1,93 @@ +import type { AppConfig } from '../lib/config' +import type { Envs } from '../lib/envs' +import type { Release } from '../lib/release' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { getApp } from '../app' +import { openDatabase } from '../database/connection' +import { DownloadsRepository } from '../modules/downloads/downloads.repository' + +const envs: Envs = { + APP_CONFIG_PATH: '/config/config.jsonc', + ENABLE_LOGS: false, + ENVIRONMENT: 'test' as any, + HTTP_TIMEOUT_MS: 3000, + LOG_LEVEL: 'fatal', + OTEL_SERVICE_NAME: 'jack-server', + PORT: 3000, + NODE_ENV: 'test', +} + +const config: AppConfig = { + jack: { baseUrl: 'http://localhost:3000', apiKey: 'test-api-key' }, + downloads: { watchPath: '/tmp/watch', completedPath: '/tmp/completed' }, + servers: [], + peers: [], +} + +const release: Release = { + id: 'remote:movie:1', + title: 'Movie.2024.1080p', + filename: 'Movie.2024.1080p.mkv', + category: 2000, + size: 100, +} + +let tempDir: string + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'jack-downloads-api-')) +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +function seed(repository: DownloadsRepository) { + return repository.create({ + torrentFilename: 'movie.torrent', + peerId: 'peer-1', + peerName: 'Friend Jack', + itemId: 'movie:1', + filename: release.filename, + destPath: join(tempDir, release.filename), + partPath: join(tempDir, `${release.filename}.part`), + releaseSize: release.size, + release, + }) +} + +describe('downloads API', () => { + test('GET /downloads lists persisted downloads', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const created = seed(repository) + + const app = getApp(envs, config, { servers: [], peers: [] }, { downloadsRepository: repository }) + const response = await app.request('/downloads', { headers: { 'X-Api-Key': 'test-api-key' } }) + const body = await response.json() as { downloads: Array<{ id: number, torrentFilename: string }> } + + expect(response.status).toBe(200) + expect(body.downloads).toHaveLength(1) + expect(body.downloads[0]?.id).toBe(created.id) + expect(body.downloads[0]?.torrentFilename).toBe('movie.torrent') + handle.close() + }) + + test('GET /downloads/:id returns one download or 404', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const created = seed(repository) + + const app = getApp(envs, config, { servers: [], peers: [] }, { downloadsRepository: repository }) + const found = await app.request(`/downloads/${created.id}`, { headers: { 'X-Api-Key': 'test-api-key' } }) + const missing = await app.request('/downloads/999999', { headers: { 'X-Api-Key': 'test-api-key' } }) + + expect(found.status).toBe(200) + expect((await found.json() as { id: number }).id).toBe(created.id) + expect(missing.status).toBe(404) + handle.close() + }) +}) diff --git a/apps/backend/src/__tests__/downloads-service.test.ts b/apps/backend/src/__tests__/downloads-service.test.ts new file mode 100644 index 0000000..ff12025 --- /dev/null +++ b/apps/backend/src/__tests__/downloads-service.test.ts @@ -0,0 +1,153 @@ +import type { Release } from '../lib/release' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { openDatabase } from '../database/connection' +import { DownloadsRepository } from '../modules/downloads/downloads.repository' +import { DownloadsService } from '../modules/downloads/downloads.service' +import { createTorrentStub } from '../modules/torznab/torrent' + +const release: Release = { + id: 'remote:movie:1', + title: 'Movie.2024.1080p', + filename: 'Movie.2024.1080p.mkv', + category: 2000, + size: 10, +} + +let tempDir: string +let watchPath: string +let completedPath: string + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'jack-downloads-service-')) + watchPath = join(tempDir, 'watch') + completedPath = join(tempDir, 'completed') + await Bun.$`mkdir -p ${watchPath} ${completedPath}`.quiet() +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +function fakePeer(overrides: Partial> = {}) { + return { + id: 'peer-1', + name: 'Friend Jack', + url: 'http://peer.test', + getRelease: overrides.getRelease ?? (async () => release), + downloadFile: overrides.downloadFile ?? (async (_itemId: string, _destPath: string, options: any) => { + await options.onProgress({ type: 'headers', expectedBytes: 10, expectedBytesSource: 'content_length', expectedBytesMismatch: false }) + await options.onProgress({ type: 'progress', downloadedBytes: 4, expectedBytes: 10 }) + await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 }) + }), + } +} + +function fakeDestination() { + return { isInitialized: true, canDestination: true, name: 'Radarr', triggerImport: async () => {} } +} + +async function writeTorrent(filename = 'movie.torrent') { + const filePath = join(watchPath, filename) + await writeFile(filePath, createTorrentStub({ name: release.title, size: release.size, peerId: 'peer-1', itemId: 'movie:1' })) + return filePath +} + +describe('DownloadsService download progress persistence', () => { + test('creates a row only after release metadata resolves and moves it to import_queued', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const calls: any[] = [] + const peer = fakePeer({ + downloadFile: async (itemId: string, destPath: string, options: any) => { + calls.push({ itemId, destPath, options }) + await options.onProgress({ type: 'headers', expectedBytes: 10, expectedBytesSource: 'content_length', expectedBytesMismatch: false }) + await options.onProgress({ type: 'progress', downloadedBytes: 4, expectedBytes: 10 }) + await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 }) + }, + }) + const service = new DownloadsService({ completedPath }, [peer as any], [fakeDestination() as any], repository) + const filePath = await writeTorrent() + + await service.processTorrentFile(filePath, 'movie.torrent') + + // The service forwards dest/part/size/torrentFilename into downloadFile. + expect(calls).toHaveLength(1) + expect(calls[0].destPath).toBe(join(completedPath, release.filename)) + expect(calls[0].options.partPath).toBe(`${join(completedPath, release.filename)}.part`) + expect(calls[0].options.releaseSize).toBe(10) + expect(calls[0].options.torrentFilename).toBe('movie.torrent') + + const downloads = repository.list() + expect(downloads).toHaveLength(1) + expect(downloads[0]?.torrentFilename).toBe('movie.torrent') + expect(downloads[0]?.filename).toBe(release.filename) + expect(downloads[0]?.destPath).toBe(join(completedPath, release.filename)) + expect(downloads[0]?.partPath).toBe(`${join(completedPath, release.filename)}.part`) + expect(downloads[0]?.releaseSize).toBe(10) + expect(downloads[0]?.expectedBytes).toBe(10) + expect(downloads[0]?.downloadedBytes).toBe(10) + expect(downloads[0]?.status).toBe('import_queued') + handle.close() + }) + + test('does not create a row when release metadata lookup fails', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const peer = fakePeer({ getRelease: async () => { + throw new Error('metadata failed') + } }) + const service = new DownloadsService({ completedPath }, [peer as any], [], repository) + const filePath = await writeTorrent() + + await service.processTorrentFile(filePath, 'movie.torrent') + + expect(repository.list()).toHaveLength(0) + handle.close() + }) + + test('rejects a peer release with a path-traversal filename and does not write outside completedPath', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const writtenPaths: string[] = [] + const peer = fakePeer({ + getRelease: async () => ({ ...release, filename: '../../evil.mkv' }), + downloadFile: async (_itemId: string, destPath: string) => { + writtenPaths.push(destPath) + }, + }) + const service = new DownloadsService({ completedPath }, [peer as any], [fakeDestination() as any], repository) + const filePath = await writeTorrent() + + await service.processTorrentFile(filePath, 'movie.torrent') + + // The unsafe name must never reach downloadFile / be written to disk. + expect(writtenPaths).toHaveLength(0) + const evilOutside = join(tempDir, 'evil.mkv') + expect(await Bun.file(evilOutside).exists()).toBe(false) + expect(await Bun.file(`${evilOutside}.part`).exists()).toBe(false) + + expect(repository.list()).toHaveLength(0) + handle.close() + }) + + test('marks an existing row failed when download fails after metadata resolves', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const peer = fakePeer({ downloadFile: async () => { + throw new Error('download failed') + } }) + const service = new DownloadsService({ completedPath }, [peer as any], [], repository) + const filePath = await writeTorrent() + + await service.processTorrentFile(filePath, 'movie.torrent') + + const downloads = repository.list() + expect(downloads).toHaveLength(1) + expect(downloads[0]?.status).toBe('failed') + expect(downloads[0]?.error).toContain('download failed') + handle.close() + }) +}) diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index 4dbfd4f..c8edc95 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -1,12 +1,17 @@ import type { AppConfig } from '../lib/config' import type { Envs } from '../lib/envs' import type { Release } from '../lib/release' +import { Database } from 'bun:sqlite' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { drizzle } from 'drizzle-orm/bun-sqlite' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { getApp } from '../app' +import { runMigrations } from '../database/connection' +import * as schema from '../database/schema' import { RadarrServerConnector } from '../lib/servers/arr/radarr' import { PeerConnector } from '../lib/servers/peer' +import { DownloadsRepository } from '../modules/downloads/downloads.repository' const RADARR_URL = 'http://radarr.test:7878' const PEER_JACK_URL = 'http://peer-jack.test:3000' @@ -86,8 +91,14 @@ const handlers = [ const server = setupServer(...handlers) +const testDatabases: Database[] = [] + beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) -afterEach(() => server.resetHandlers()) +afterEach(() => { + server.resetHandlers() + for (const db of testDatabases.splice(0)) + db.close() +}) afterAll(() => server.close()) const config: AppConfig = { @@ -129,7 +140,13 @@ function makeRadarr(overrides?: { source?: boolean, destination?: boolean }) { function createTestApp() { const radarr = markInitialized(makeRadarr()) const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' })) - return { app: getApp(envs, config, { servers: [radarr], peers: [peer] }), radarr, peer } + const database = new Database(':memory:') + testDatabases.push(database) + database.exec('pragma foreign_keys = ON') + const db = drizzle({ client: database, schema }) + runMigrations(db) + const downloadsRepository = new DownloadsRepository(db) + return { app: getApp(envs, config, { servers: [radarr], peers: [peer] }, { downloadsRepository }), radarr, peer, database, downloadsRepository } } describe('Peer API', () => { @@ -266,6 +283,28 @@ describe('Torrent download', () => { }) }) +describe('Downloads API', () => { + test('GET /downloads returns persisted download state', async () => { + const { app, downloadsRepository } = createTestApp() + downloadsRepository.create({ + torrentFilename: 'movie.torrent', + peerId: 'peer-1', + peerName: 'Friend Jack', + itemId: 'movie:1', + filename: peerRelease.filename, + destPath: '/tmp/completed/Movie.mkv', + partPath: '/tmp/completed/Movie.mkv.part', + releaseSize: peerRelease.size, + release: peerRelease, + }) + + const res = await app.request('/downloads', { headers: { 'X-Api-Key': 'test-api-key' } }) + expect(res.status).toBe(200) + const body = await res.json() as { downloads: Array<{ torrentFilename: string }> } + expect(body.downloads[0]?.torrentFilename).toBe('movie.torrent') + }) +}) + describe('Auto-registration', () => { test('registerIndexer calls the Radarr API', async () => { const radarr = markInitialized(makeRadarr()) diff --git a/apps/backend/src/__tests__/peer-download.test.ts b/apps/backend/src/__tests__/peer-download.test.ts index 63ad48c..015d15e 100644 --- a/apps/backend/src/__tests__/peer-download.test.ts +++ b/apps/backend/src/__tests__/peer-download.test.ts @@ -21,6 +21,18 @@ function markInitialized(connector: T): T { return connector } +// In this MSW/Bun version a `new Response(Uint8Array)` body reads back as 0 +// bytes through getReader(); a ReadableStream body streams correctly (and keeps +// an explicit Content-Length header), matching the connector's streaming path. +function streamOf(bytes: number[]) { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(bytes)) + controller.close() + }, + }) +} + async function waitFor(predicate: () => Promise) { for (let i = 0; i < 50; i++) { if (await predicate()) @@ -86,4 +98,112 @@ describe('PeerConnector.downloadFile', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('reports expected bytes from Content-Length and streamed progress', async () => { + server.use( + http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => { + return new Response(streamOf([1, 2, 3, 4]), { headers: { 'Content-Length': '4' } }) + }), + ) + + const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' })) + const dir = await mkdtemp(join(tmpdir(), 'jack-peer-progress-')) + const events: unknown[] = [] + + try { + await peer.downloadFile('remote1:movie:99', join(dir, 'Movie.mkv'), { + torrentFilename: 'movie.torrent', + releaseSize: 4, + onProgress: (event) => { events.push(event) }, + }) + + expect(events).toContainEqual({ type: 'headers', expectedBytes: 4, expectedBytesSource: 'content_length', expectedBytesMismatch: false }) + // The first chunk always emits a progress event (lastLoggedBytes === 0). + expect(events).toContainEqual({ type: 'progress', downloadedBytes: 4, expectedBytes: 4 }) + expect(events).toContainEqual({ type: 'completed', downloadedBytes: 4, expectedBytes: 4 }) + } + finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('reports indeterminate expected bytes when Content-Length is missing or invalid', async () => { + for (const contentLength of [null, 'not-a-number']) { + server.resetHandlers() + server.use( + http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => { + const headers = contentLength == null ? {} : { 'Content-Length': contentLength } + return new Response(streamOf([1, 2]), { headers }) + }), + ) + + const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' })) + const dir = await mkdtemp(join(tmpdir(), 'jack-peer-indeterminate-')) + const events: unknown[] = [] + + try { + await peer.downloadFile('remote1:movie:99', join(dir, 'Movie.mkv'), { + onProgress: (event) => { events.push(event) }, + }) + + expect(events).toContainEqual({ type: 'headers', expectedBytes: null, expectedBytesSource: null, expectedBytesMismatch: false }) + expect(events).toContainEqual({ type: 'completed', downloadedBytes: 2, expectedBytes: null }) + } + finally { + await rm(dir, { recursive: true, force: true }) + } + } + }) + + test('reports Content-Length mismatches against releaseSize', async () => { + server.use( + http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => { + return new Response(streamOf([1, 2, 3]), { headers: { 'Content-Length': '3' } }) + }), + ) + + const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' })) + const dir = await mkdtemp(join(tmpdir(), 'jack-peer-mismatch-')) + const events: unknown[] = [] + + try { + await peer.downloadFile('remote1:movie:99', join(dir, 'Movie.mkv'), { + releaseSize: 4, + onProgress: (event) => { events.push(event) }, + }) + + expect(events).toContainEqual({ type: 'headers', expectedBytes: 3, expectedBytesSource: 'content_length', expectedBytesMismatch: true }) + } + finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('does not fail a completed file download when the completed progress callback throws', async () => { + server.use( + http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => { + return new Response(streamOf([1, 2, 3, 4]), { headers: { 'Content-Length': '4' } }) + }), + ) + + const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' })) + const dir = await mkdtemp(join(tmpdir(), 'jack-peer-completed-callback-')) + const destPath = join(dir, 'Movie.mkv') + const partPath = `${destPath}.part` + + try { + await peer.downloadFile('remote1:movie:99', destPath, { + onProgress: (event) => { + if (event.type === 'completed') + throw new Error('tracking write failed') + }, + }) + + expect(await Bun.file(partPath).exists()).toBe(false) + expect(new Uint8Array(await Bun.file(destPath).arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4])) + } + finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index ba9ac9b..40ad9c6 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -2,6 +2,7 @@ import type { AppConfig } from './lib/config' import type { Envs } from './lib/envs' import type { ArrServerConnector } from './lib/servers/arr/base' import type { PeerConnector } from './lib/servers/peer' +import type { DownloadsRepository } from './modules/downloads/downloads.repository' import { httpInstrumentationMiddleware } from '@hono/otel' import { Hono } from 'hono' import { secureHeaders } from 'hono/secure-headers' @@ -9,6 +10,8 @@ import { getAppEnvs, isOtelEnabled } from './lib/envs' import { handleError } from './middleware/handle-error' import { logRequests } from './middleware/log-requests' import { requireApiKey } from './middleware/require-auth' +import { DownloadsController } from './modules/downloads/downloads.controller' +import { getDownloadsRouter } from './modules/downloads/downloads.router' import { ItemsController } from './modules/items/items.controller' import { getItemsRouter } from './modules/items/items.router' import { PeerController } from './modules/peer/peer.controller' @@ -24,18 +27,24 @@ interface Connectors { peers: PeerConnector[] } -export function getApp(envs: Envs, config: AppConfig, connectors: Connectors) { +interface AppServices { + downloadsRepository?: DownloadsRepository +} + +export function getApp(envs: Envs, config: AppConfig, connectors: Connectors, services: AppServices = {}) { const app = new Hono() // Controllers const serversController = new ServersController({ servers: connectors.servers, peers: connectors.peers }) const itemsController = new ItemsController({ sources: connectors.servers }) const peerController = new PeerController(connectors.servers) + const downloadsController = services.downloadsRepository ? new DownloadsController(services.downloadsRepository) : null // Routers const serversRouter = getServersRouter(serversController) const itemsRouter = getItemsRouter(itemsController) const peerRouter = getPeerRouter(peerController) + const downloadsRouter = downloadsController ? getDownloadsRouter(downloadsController) : null app.use('*', secureHeaders()) @@ -58,6 +67,9 @@ export function getApp(envs: Envs, config: AppConfig, connectors: Connectors) { app.route('/servers', serversRouter) app.route('/items', itemsRouter) + if (downloadsRouter) + app.route('/downloads', downloadsRouter) + if (config.jack) { const jackConfig = config.jack diff --git a/apps/backend/src/database/connection.ts b/apps/backend/src/database/connection.ts new file mode 100644 index 0000000..485775c --- /dev/null +++ b/apps/backend/src/database/connection.ts @@ -0,0 +1,51 @@ +import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite' +import { mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { Database } from 'bun:sqlite' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import { migrate } from 'drizzle-orm/bun-sqlite/migrator' +import * as schema from './schema' + +export type AppDatabase = BunSQLiteDatabase + +export interface DatabaseHandle { + db: AppDatabase + sqlite: Database + path: string + close: () => void +} + +// Migrations live at apps/backend/drizzle. import.meta.dir is +// apps/backend/src/database, so go up two levels. Resolving relative to this +// module (not cwd) keeps it correct under `bun src/index.ts` and bun:test. +export const MIGRATIONS_FOLDER = join(import.meta.dir, '../../drizzle') + +export function getDatabasePath(appConfigPath: string) { + return join(dirname(appConfigPath), 'database.sqlite') +} + +// Applies all pending Drizzle migrations. Idempotent: drizzle tracks applied +// migrations in __drizzle_migrations, so this is safe to run on every boot and +// in every test against a fresh in-memory DB. +export function runMigrations(db: AppDatabase) { + migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }) +} + +export async function openDatabase({ appConfigPath }: { appConfigPath: string }): Promise { + const path = getDatabasePath(appConfigPath) + await mkdir(dirname(path), { recursive: true }) + + const sqlite = new Database(path) + sqlite.exec('pragma journal_mode = WAL') + sqlite.exec('pragma foreign_keys = ON') + + const db = drizzle({ client: sqlite, schema }) + runMigrations(db) + + return { + db, + sqlite, + path, + close: () => sqlite.close(), + } +} diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts new file mode 100644 index 0000000..3168888 --- /dev/null +++ b/apps/backend/src/database/schema.ts @@ -0,0 +1,36 @@ +import { sql } from 'drizzle-orm' +import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +export const DOWNLOAD_STATUSES = ['downloading', 'completed', 'failed', 'import_queued'] as const +export type DownloadStatus = typeof DOWNLOAD_STATUSES[number] +export type ExpectedBytesSource = 'content_length' + +export const downloads = sqliteTable('downloads', { + id: integer('id').primaryKey({ autoIncrement: true }), + torrentFilename: text('torrent_filename').notNull(), + peerId: text('peer_id').notNull(), + peerName: text('peer_name').notNull(), + itemId: text('item_id').notNull(), + filename: text('filename').notNull(), + destPath: text('dest_path').notNull(), + partPath: text('part_path').notNull(), + releaseSize: integer('release_size').notNull(), + releaseJson: text('release_json').notNull(), + expectedBytes: integer('expected_bytes'), + expectedBytesSource: text('expected_bytes_source').$type(), + expectedBytesMismatch: integer('expected_bytes_mismatch', { mode: 'boolean' }).notNull().default(false), + downloadedBytes: integer('downloaded_bytes').notNull().default(0), + status: text('status').$type().notNull(), + startedAt: text('started_at').notNull(), + updatedAt: text('updated_at').notNull(), + completedAt: text('completed_at'), + error: text('error'), +}, t => [ + check('downloads_status_check', sql`${t.status} in ('downloading', 'completed', 'failed', 'import_queued')`), + check('downloads_expected_bytes_source_check', sql`${t.expectedBytesSource} is null or ${t.expectedBytesSource} = 'content_length'`), + index('downloads_status_idx').on(t.status), + index('downloads_updated_at_idx').on(t.updatedAt), +]) + +export type DownloadRow = typeof downloads.$inferSelect +export type NewDownloadRow = typeof downloads.$inferInsert diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index d77585b..db0c1e5 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,12 +1,15 @@ import process from 'node:process' import { getApp } from './app' +import { openDatabase } from './database/connection' import { shutdownTelemetry } from './instrumentation' import { getAppConfig } from './lib/config' import { getAppEnvs } from './lib/envs' import { FetchError } from './lib/errors/FetchError' import { initializeConnectors } from './lib/servers' import { logger } from './logger' -import { BlackholeWatcher } from './modules/downloads/blackhole' +import { BlackholeWatcher } from './modules/downloads/blackhole.watcher' +import { DownloadsRepository } from './modules/downloads/downloads.repository' +import { DownloadsService } from './modules/downloads/downloads.service' function logRegistrationFailure(what: string, destName: string | undefined, err: unknown) { if (err instanceof FetchError) { @@ -26,7 +29,14 @@ const config = await getAppConfig(envs) const connectors = await initializeConnectors(config) const destinations = connectors.servers.filter(s => s.canDestination) -const app = getApp(envs, config, connectors) +const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH }) +const downloadsRepository = new DownloadsRepository(database.db) +const reconciledDownloads = await downloadsRepository.reconcileStaleDownloads() + +if (reconciledDownloads > 0) + logger.warn({ downloads: reconciledDownloads, databasePath: database.path }, 'Reconciled stale downloads from previous Jack run') + +const app = getApp(envs, config, connectors, { downloadsRepository }) const server = Bun.serve({ fetch: app.fetch, }) @@ -34,6 +44,7 @@ const server = Bun.serve({ logger.info({ port: server.port, configPath: envs.APP_CONFIG_PATH, + databasePath: database.path, sources: connectors.servers.filter(c => c.isInitialized && c.canSource).length, peers: connectors.peers.filter(c => c.isInitialized).length, destinations: destinations.filter(c => c.isInitialized).length, @@ -90,15 +101,17 @@ if (config.jack) { } // Start blackhole watcher -let blackhole: BlackholeWatcher | null = null +let blackholeWatcher: BlackholeWatcher | null = null if (config.downloads) { - blackhole = new BlackholeWatcher(config.downloads, connectors.peers, destinations) - await blackhole.start() + const downloadsService = new DownloadsService(config.downloads, connectors.peers, destinations, downloadsRepository) + blackholeWatcher = new BlackholeWatcher(config.downloads, downloadsService) + await blackholeWatcher.start() } process.on('SIGINT', async () => { logger.info('SIGINT received, exiting') - blackhole?.stop() + blackholeWatcher?.stop() + database.close() server.stop() await shutdownTelemetry() process.exit(0) @@ -106,7 +119,8 @@ process.on('SIGINT', async () => { process.on('SIGTERM', async () => { logger.info('SIGTERM received, exiting') - blackhole?.stop() + blackholeWatcher?.stop() + database.close() server.stop() await shutdownTelemetry() process.exit(0) diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts index b1f14d5..0099494 100644 --- a/apps/backend/src/lib/servers/peer.ts +++ b/apps/backend/src/lib/servers/peer.ts @@ -13,9 +13,29 @@ const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 * 1024 // 100GB const DOWNLOAD_PROGRESS_INTERVAL_MS = 10_000 const DOWNLOAD_PROGRESS_BYTES = 64 * 1024 * 1024 +export type PeerDownloadProgressEvent + = | { type: 'headers', expectedBytes: number | null, expectedBytesSource: 'content_length' | null, expectedBytesMismatch: boolean } + | { type: 'progress', downloadedBytes: number, expectedBytes: number | null } + | { type: 'completed', downloadedBytes: number, expectedBytes: number | null } + export interface PeerDownloadOptions { timeoutMs?: number torrentFilename?: string + partPath?: string + releaseSize?: number + onProgress?: (event: PeerDownloadProgressEvent) => void | Promise +} + +function parseContentLength(headers: Headers): number | null { + const raw = headers.get('Content-Length') + if (!raw) + return null + + const parsed = Number(raw) + if (!Number.isSafeInteger(parsed) || parsed < 0) + return null + + return parsed } /** @@ -132,7 +152,7 @@ export class PeerConnector extends ServerConnector { const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000 const torrentFilename = options.torrentFilename const url = new URL(`/peer/items/${encodeURIComponent(id)}/file`, this.url) - const partPath = `${destPath}.part` + const partPath = options.partPath ?? `${destPath}.part` span.setAttributes({ 'http.request.timeout_ms': timeoutMs, 'url.path': url.pathname, @@ -153,12 +173,33 @@ export class PeerConnector extends ServerConnector { throw new Error('Peer returned a file response without a body') } - const contentLength = Number(response.headers.get('Content-Length') || 0) - span.setAttribute('download.expected_bytes', contentLength) - if (contentLength > MAX_DOWNLOAD_BYTES) { - throw new Error(`File too large: ${contentLength} bytes exceeds ${MAX_DOWNLOAD_BYTES} byte limit`) + const expectedBytes = parseContentLength(response.headers) + const expectedBytesMismatch = expectedBytes != null && options.releaseSize != null && expectedBytes !== options.releaseSize + if (expectedBytes != null) + span.setAttribute('download.expected_bytes', expectedBytes) + span.setAttribute('download.expected_bytes_source', expectedBytes == null ? 'unknown' : 'content_length') + span.setAttribute('download.expected_bytes_mismatch', expectedBytesMismatch) + + if (expectedBytesMismatch) { + logger.warn({ + id, + torrentFilename, + releaseSize: options.releaseSize, + expectedBytes, + peer: this.name, + }, 'Peer file Content-Length differs from release metadata size') } + await options.onProgress?.({ + type: 'headers', + expectedBytes, + expectedBytesSource: expectedBytes == null ? null : 'content_length', + expectedBytesMismatch, + }) + + if (expectedBytes != null && expectedBytes > MAX_DOWNLOAD_BYTES) + throw new Error(`File too large: ${expectedBytes} bytes exceeds ${MAX_DOWNLOAD_BYTES} byte limit`) + const reader = response.body.getReader() const writer = Bun.file(partPath).writer() let downloadedBytes = 0 @@ -192,7 +233,8 @@ export class PeerConnector extends ServerConnector { const shouldLogProgress = downloadedBytes - lastLoggedBytes >= DOWNLOAD_PROGRESS_BYTES || now - lastLoggedAt >= DOWNLOAD_PROGRESS_INTERVAL_MS if (lastLoggedBytes === 0 || shouldLogProgress) { await writer.flush() - logger.debug({ id, torrentFilename, destPath, partPath, downloadedBytes, expectedBytes: contentLength, peer: this.name }, 'Download progress from peer') + logger.debug({ id, torrentFilename, destPath, partPath, downloadedBytes, expectedBytes, peer: this.name }, 'Download progress from peer') + await options.onProgress?.({ type: 'progress', downloadedBytes, expectedBytes }) lastLoggedAt = now lastLoggedBytes = downloadedBytes } @@ -201,12 +243,18 @@ export class PeerConnector extends ServerConnector { endWriter() reader.releaseLock() - if (contentLength > 0 && downloadedBytes !== contentLength) { - throw new Error(`Incomplete file download: got ${downloadedBytes} bytes, expected ${contentLength}`) - } + if (expectedBytes != null && downloadedBytes !== expectedBytes) + throw new Error(`Incomplete file download: got ${downloadedBytes} bytes, expected ${expectedBytes}`) await rename(partPath, destPath) span.setAttribute('download.downloaded_bytes', downloadedBytes) + try { + await options.onProgress?.({ type: 'completed', downloadedBytes, expectedBytes }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.error({ id, torrentFilename, destPath, downloadedBytes, expectedBytes, peer: this.name, error: message }, 'Completed download progress callback failed') + } } catch (err) { try { diff --git a/apps/backend/src/modules/downloads/blackhole.ts b/apps/backend/src/modules/downloads/blackhole.ts deleted file mode 100644 index 58afe9c..0000000 --- a/apps/backend/src/modules/downloads/blackhole.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { AppConfig } from '../../lib/config' -import type { ArrServerConnector } from '../../lib/servers/arr/base' -import type { PeerConnector } from '../../lib/servers/peer' -import { Buffer } from 'node:buffer' -import { watch } from 'node:fs' -import { readdir, unlink } from 'node:fs/promises' -import { join } from 'node:path' -import { withSpan } from '../../lib/tracing' -import { logger } from '../../logger' -import { parseTorrentStub } from '../torznab/torrent' - -const STABILITY_DELAY_MS = 500 -const STABILITY_RETRIES = 3 - -export class BlackholeWatcher { - private watcher: ReturnType | null = null - private processing = new Set() - - constructor( - private readonly config: NonNullable, - private readonly peers: PeerConnector[], - private readonly destinations: ArrServerConnector[], - ) {} - - async start() { - const { watchPath, completedPath } = this.config - - await Bun.$`mkdir -p ${watchPath} ${completedPath}`.quiet() - - // Process any existing .torrent files - await this.scanExisting() - - this.watcher = watch(watchPath, async (_event, filename) => { - if (!filename?.endsWith('.torrent')) - return - - const filePath = join(watchPath, filename) - logger.debug({ torrentFilename: filename, filePath, watchPath }, 'Torrent file detected in watch folder') - if (!await this.waitForStableFile(filePath)) - return - await this.processTorrent(filePath, filename) - }) - - logger.info({ watchPath, completedPath }, 'Blackhole watcher started') - } - - stop() { - this.watcher?.close() - this.watcher = null - logger.info('Blackhole watcher stopped') - } - - private async waitForStableFile(filePath: string): Promise { - let lastSize = -1 - for (let i = 0; i < STABILITY_RETRIES; i++) { - await Bun.sleep(STABILITY_DELAY_MS) - const file = Bun.file(filePath) - if (!await file.exists()) - return false - const size = file.size - if (size === lastSize && size > 0) - return true - lastSize = size - } - return lastSize > 0 - } - - private async scanExisting() { - logger.debug({ watchPath: this.config.watchPath }, 'Starting watch folder scan') - - try { - const files = await readdir(this.config.watchPath) - const torrentFiles = files.filter(file => file.endsWith('.torrent')) - - logger.debug({ watchPath: this.config.watchPath, filesFound: torrentFiles.length }, 'Watch folder scan complete') - - for (const file of torrentFiles) { - const filePath = join(this.config.watchPath, file) - logger.debug({ torrentFilename: file, filePath, watchPath: this.config.watchPath }, 'Torrent file found in watch folder scan') - await this.processTorrent(filePath, file) - } - } - catch (err) { - // Directory might not exist yet - const message = err instanceof Error ? err.message : String(err) - logger.warn({ watchPath: this.config.watchPath, error: message }, 'Watch folder scan failed') - } - } - - private async processTorrent(filePath: string, filename: string) { - if (this.processing.has(filename)) - return - this.processing.add(filename) - - try { - await withSpan('blackhole.process_torrent', { - 'torrent.filename': filename, - }, async (span) => { - const file = Bun.file(filePath) - if (!await file.exists()) { - span.setAttribute('torrent.exists', false) - return - } - - span.setAttribute('torrent.exists', true) - const data = Buffer.from(await file.arrayBuffer()) - const stub = parseTorrentStub(data) - - if (!stub) { - span.setAttribute('torrent.stub.valid', false) - logger.warn({ torrentFilename: filename, filename }, 'Could not parse torrent stub, skipping') - return - } - - span.setAttribute('torrent.stub.valid', true) - const { peerId, itemId } = stub - span.setAttributes({ - 'peer.id': peerId, - 'item.id': itemId, - }) - - // No isInitialized pre-filter: the peer methods are guarded by - // @requireInitialization, so a peer that was down at boot gets - // re-initialized lazily on the getRelease/downloadFile calls below. - const peer = this.peers.find(p => p.id === peerId) - - if (!peer) { - span.setAttribute('peer.found', false) - logger.error({ torrentFilename: filename, peerId, filename }, 'Peer not found') - return - } - - span.setAttributes({ - 'peer.found': true, - 'peer.name': peer.name ?? peer.url, - }) - - const release = await peer.getRelease(itemId) - const destPath = join(this.config.completedPath, release.filename) - span.setAttributes({ - 'release.filename': release.filename, - 'release.size': release.size, - }) - - await peer.downloadFile(itemId, destPath, { torrentFilename: filename }) - - // Remove the .torrent stub - await unlink(filePath) - - // Trigger import scan on all destinations - await this.triggerImport(filename) - - logger.info({ torrentFilename: filename, filename: release.filename }, 'Download complete, triggered import') - }) - } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.error({ torrentFilename: filename, filename, error: message }, 'Failed to process torrent') - } - finally { - this.processing.delete(filename) - } - } - - private async triggerImport(torrentFilename: string) { - for (const dest of this.destinations.filter(d => d.isInitialized && d.canDestination)) { - try { - await withSpan('blackhole.trigger_import', { - 'torrent.filename': torrentFilename, - 'destination.name': dest.name, - }, async () => { - await dest.triggerImport(this.config.completedPath) - }) - } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.error({ torrentFilename, destination: dest.name, error: message }, 'Failed to trigger import') - } - } - } -} diff --git a/apps/backend/src/modules/downloads/blackhole.watcher.ts b/apps/backend/src/modules/downloads/blackhole.watcher.ts new file mode 100644 index 0000000..5a65968 --- /dev/null +++ b/apps/backend/src/modules/downloads/blackhole.watcher.ts @@ -0,0 +1,102 @@ +import type { AppConfig } from '../../lib/config' +import type { DownloadsService } from './downloads.service' +import { watch } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { logger } from '../../logger' + +const STABILITY_DELAY_MS = 500 +const STABILITY_RETRIES = 3 + +export class BlackholeWatcher { + private watcher: ReturnType | null = null + private processing = new Set() + + constructor( + private readonly config: NonNullable, + private readonly downloadsService: DownloadsService, + ) {} + + async start() { + const { watchPath, completedPath } = this.config + + await Bun.$`mkdir -p ${watchPath} ${completedPath}`.quiet() + + // Register the watcher BEFORE scanning so a .torrent dropped during the + // scan is not missed. The `processing` Set dedupes a file caught by both + // the watch event and the scan. + this.watcher = watch(watchPath, async (_event, filename) => { + if (!filename) + return + + const torrentFilename = String(filename) + if (!torrentFilename.endsWith('.torrent')) + return + + const filePath = join(watchPath, torrentFilename) + logger.debug({ torrentFilename, filePath, watchPath }, 'Torrent file detected in watch folder') + if (!await this.waitForStableFile(filePath)) + return + await this.processTorrentFile(filePath, torrentFilename) + }) + + await this.scanExisting() + + logger.info({ watchPath, completedPath }, 'Blackhole watcher started') + } + + stop() { + this.watcher?.close() + this.watcher = null + logger.info('Blackhole watcher stopped') + } + + private async waitForStableFile(filePath: string): Promise { + let lastSize = -1 + for (let i = 0; i < STABILITY_RETRIES; i++) { + await Bun.sleep(STABILITY_DELAY_MS) + const file = Bun.file(filePath) + if (!await file.exists()) + return false + const size = file.size + if (size === lastSize && size > 0) + return true + lastSize = size + } + return lastSize > 0 + } + + private async scanExisting() { + logger.debug({ watchPath: this.config.watchPath }, 'Starting watch folder scan') + + try { + const files = await readdir(this.config.watchPath) + const torrentFiles = files.filter(file => file.endsWith('.torrent')) + + logger.debug({ watchPath: this.config.watchPath, filesFound: torrentFiles.length }, 'Watch folder scan complete') + + for (const file of torrentFiles) { + const filePath = join(this.config.watchPath, file) + logger.debug({ torrentFilename: file, filePath, watchPath: this.config.watchPath }, 'Torrent file found in watch folder scan') + await this.processTorrentFile(filePath, file) + } + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.warn({ watchPath: this.config.watchPath, error: message }, 'Watch folder scan failed') + } + } + + private async processTorrentFile(filePath: string, filename: string) { + if (this.processing.has(filename)) + return + this.processing.add(filename) + + try { + await this.downloadsService.processTorrentFile(filePath, filename) + } + finally { + this.processing.delete(filename) + } + } +} diff --git a/apps/backend/src/modules/downloads/downloads.controller.ts b/apps/backend/src/modules/downloads/downloads.controller.ts new file mode 100644 index 0000000..57b976d --- /dev/null +++ b/apps/backend/src/modules/downloads/downloads.controller.ts @@ -0,0 +1,13 @@ +import type { DownloadsRepository } from './downloads.repository' + +export class DownloadsController { + constructor(private readonly repository: DownloadsRepository) {} + + listDownloads() { + return { downloads: this.repository.list() } + } + + getDownload(id: number) { + return this.repository.get(id) + } +} diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts new file mode 100644 index 0000000..2e066af --- /dev/null +++ b/apps/backend/src/modules/downloads/downloads.repository.ts @@ -0,0 +1,163 @@ +import type { AppDatabase } from '../../database/connection' +import type { DownloadRow, DownloadStatus, ExpectedBytesSource, NewDownloadRow } from '../../database/schema' +import type { Release } from '../../lib/release' +import { desc, eq } from 'drizzle-orm' +import { downloads } from '../../database/schema' + +export interface DownloadRecord { + id: number + torrentFilename: string + peerId: string + peerName: string + itemId: string + filename: string + destPath: string + partPath: string + releaseSize: number + release: Release + expectedBytes: number | null + expectedBytesSource: ExpectedBytesSource | null + expectedBytesMismatch: boolean + downloadedBytes: number + status: DownloadStatus + startedAt: string + updatedAt: string + completedAt: string | null + error: string | null +} + +export interface CreateDownloadInput { + torrentFilename: string + peerId: string + peerName: string + itemId: string + filename: string + destPath: string + partPath: string + releaseSize: number + release: Release +} + +function nowIso() { + return new Date().toISOString() +} + +function toRecord(row: DownloadRow): DownloadRecord { + return { + id: row.id, + torrentFilename: row.torrentFilename, + peerId: row.peerId, + peerName: row.peerName, + itemId: row.itemId, + filename: row.filename, + destPath: row.destPath, + partPath: row.partPath, + releaseSize: row.releaseSize, + release: JSON.parse(row.releaseJson) as Release, + expectedBytes: row.expectedBytes, + expectedBytesSource: row.expectedBytesSource ?? null, + expectedBytesMismatch: row.expectedBytesMismatch, + downloadedBytes: row.downloadedBytes, + status: row.status, + startedAt: row.startedAt, + updatedAt: row.updatedAt, + completedAt: row.completedAt, + error: row.error, + } +} + +export class DownloadsRepository { + constructor(private readonly db: AppDatabase) {} + + create(input: CreateDownloadInput): DownloadRecord { + const timestamp = nowIso() + const values: NewDownloadRow = { + torrentFilename: input.torrentFilename, + peerId: input.peerId, + peerName: input.peerName, + itemId: input.itemId, + filename: input.filename, + destPath: input.destPath, + partPath: input.partPath, + releaseSize: input.releaseSize, + releaseJson: JSON.stringify(input.release), + downloadedBytes: 0, + status: 'downloading', + startedAt: timestamp, + updatedAt: timestamp, + } + + const row = this.db.insert(downloads).values(values).returning().get() + return toRecord(row) + } + + get(id: number): DownloadRecord | null { + const row = this.db.select().from(downloads).where(eq(downloads.id, id)).get() + return row ? toRecord(row) : null + } + + list(): DownloadRecord[] { + // Secondary sort by id breaks ties when two rows share an updatedAt + // (ISO timestamps can collide within the same millisecond). + return this.db.select().from(downloads).orderBy(desc(downloads.updatedAt), desc(downloads.id)).all().map(toRecord) + } + + setExpectedBytes(id: number, expectedBytes: number | null, source: ExpectedBytesSource | null, mismatch = false): void { + this.db.update(downloads) + .set({ expectedBytes, expectedBytesSource: source, expectedBytesMismatch: mismatch, updatedAt: nowIso() }) + .where(eq(downloads.id, id)) + .run() + } + + updateProgress(id: number, downloadedBytes: number): void { + this.db.update(downloads) + .set({ downloadedBytes, updatedAt: nowIso() }) + .where(eq(downloads.id, id)) + .run() + } + + markCompleted(id: number, downloadedBytes: number): void { + const timestamp = nowIso() + this.db.update(downloads) + .set({ status: 'completed', downloadedBytes, completedAt: timestamp, updatedAt: timestamp, error: null }) + .where(eq(downloads.id, id)) + .run() + } + + markImportQueued(id: number): void { + this.db.update(downloads) + .set({ status: 'import_queued', updatedAt: nowIso() }) + .where(eq(downloads.id, id)) + .run() + } + + markFailed(id: number, error: string): void { + this.db.update(downloads) + .set({ status: 'failed', error, updatedAt: nowIso() }) + .where(eq(downloads.id, id)) + .run() + } + + async reconcileStaleDownloads(): Promise { + const staleRows = this.db.select().from(downloads).where(eq(downloads.status, 'downloading')).all() + + for (const row of staleRows) { + const partFile = Bun.file(row.partPath) + const partExists = await partFile.exists() + const downloadedBytes = partExists ? partFile.size : row.downloadedBytes + this.db.update(downloads) + .set({ + status: 'failed', + downloadedBytes, + error: partExists + ? `stale download after Jack restart; found .part file with ${downloadedBytes} bytes` + : 'stale download after Jack restart; .part file was not found', + updatedAt: nowIso(), + }) + .where(eq(downloads.id, row.id)) + .run() + } + + return staleRows.length + } +} diff --git a/apps/backend/src/modules/downloads/downloads.router.ts b/apps/backend/src/modules/downloads/downloads.router.ts new file mode 100644 index 0000000..338d385 --- /dev/null +++ b/apps/backend/src/modules/downloads/downloads.router.ts @@ -0,0 +1,28 @@ +import type { DownloadsController } from './downloads.controller' +import { Hono } from 'hono' +import { validator as zValidator } from 'hono-openapi' +import z from 'zod' + +export function getDownloadsRouter(controller: DownloadsController) { + const app = new Hono() + + app.get('/', (c) => { + return c.json(controller.listDownloads()) + }) + + app.get( + '/:id', + zValidator('param', z.object({ id: z.coerce.number().int().positive() })), + (c) => { + const { id } = c.req.valid('param') + const download = controller.getDownload(id) + + if (!download) + return c.json({ error: 'Not found' }, 404) + + return c.json(download) + }, + ) + + return app +} diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts new file mode 100644 index 0000000..e60ae81 --- /dev/null +++ b/apps/backend/src/modules/downloads/downloads.service.ts @@ -0,0 +1,143 @@ +import type { AppConfig } from '../../lib/config' +import type { ArrServerConnector } from '../../lib/servers/arr/base' +import type { PeerConnector, PeerDownloadProgressEvent } from '../../lib/servers/peer' +import type { DownloadsRepository } from './downloads.repository' +import { Buffer } from 'node:buffer' +import { unlink } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { withSpan } from '../../lib/tracing' +import { logger } from '../../logger' +import { parseTorrentStub } from '../torznab/torrent' + +export class DownloadsService { + constructor( + private readonly config: Pick, 'completedPath'>, + private readonly peers: PeerConnector[], + private readonly destinations: ArrServerConnector[], + private readonly downloadsRepository?: DownloadsRepository, + ) {} + + async processTorrentFile(filePath: string, filename: string) { + try { + await withSpan('blackhole.process_torrent', { 'torrent.filename': filename }, async (span) => { + const file = Bun.file(filePath) + if (!await file.exists()) { + span.setAttribute('torrent.exists', false) + return + } + + span.setAttribute('torrent.exists', true) + const data = Buffer.from(await file.arrayBuffer()) + const stub = parseTorrentStub(data) + + if (!stub) { + span.setAttribute('torrent.stub.valid', false) + logger.warn({ torrentFilename: filename, filename }, 'Could not parse torrent stub, skipping') + return + } + + span.setAttribute('torrent.stub.valid', true) + const { peerId, itemId } = stub + span.setAttributes({ 'peer.id': peerId, 'item.id': itemId }) + + const peer = this.peers.find(p => p.id === peerId) + if (!peer) { + span.setAttribute('peer.found', false) + logger.error({ torrentFilename: filename, peerId, filename }, 'Peer not found') + return + } + + span.setAttributes({ 'peer.found': true, 'peer.name': peer.name ?? peer.url }) + + const release = await peer.getRelease(itemId) + + // `release.filename` is peer-controlled and only validated as a string. + // Force it to a plain basename inside `completedPath` so a value like + // `../../evil.mkv` or an absolute path cannot escape the directory. + // Reject (rather than silently rewrite) anything that is not already a + // plain filename, so a malicious peer cannot smuggle in path separators. + const safeName = basename(release.filename) + const isSafeName = safeName.length > 0 && safeName !== '.' && safeName !== '..' + && !safeName.includes('/') && !safeName.includes('\\') + && release.filename === safeName + + if (!isSafeName) + throw new Error(`Unsafe release filename from peer: ${release.filename}`) + + const destPath = join(this.config.completedPath, safeName) + const partPath = `${destPath}.part` + span.setAttributes({ 'release.filename': safeName, 'release.size': release.size }) + + const download = this.downloadsRepository?.create({ + torrentFilename: filename, + peerId, + peerName: peer.name ?? peer.url, + itemId, + filename: safeName, + destPath, + partPath, + releaseSize: release.size, + release, + }) + + const onProgress = async (event: PeerDownloadProgressEvent) => { + if (!download) + return + + if (event.type === 'headers') { + this.downloadsRepository?.setExpectedBytes(download.id, event.expectedBytes, event.expectedBytesSource, event.expectedBytesMismatch) + return + } + + if (event.type === 'progress') { + this.downloadsRepository?.updateProgress(download.id, event.downloadedBytes) + return + } + + this.downloadsRepository?.markCompleted(download.id, event.downloadedBytes) + } + + // Everything after the row is created is wrapped so any failure + // (download, stub unlink, or import trigger) marks the row failed + // instead of leaving it stuck in `completed`/`downloading`. + // `import_queued` means: the file downloaded AND triggerImport was + // attempted (best-effort per destination โ€” see triggerImport below). + try { + await peer.downloadFile(itemId, destPath, { torrentFilename: filename, partPath, releaseSize: release.size, onProgress }) + await unlink(filePath) + await this.triggerImport(filename) + + if (download) + this.downloadsRepository?.markImportQueued(download.id) + } + catch (err) { + if (download) { + const message = err instanceof Error ? err.message : String(err) + this.downloadsRepository?.markFailed(download.id, message) + } + throw err + } + + logger.info({ torrentFilename: filename, filename: safeName }, 'Download complete, triggered import') + }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.error({ torrentFilename: filename, filename, error: message }, 'Failed to process torrent') + } + } + + private async triggerImport(torrentFilename: string) { + for (const dest of this.destinations.filter(d => d.isInitialized && d.canDestination)) { + try { + await withSpan('blackhole.trigger_import', { 'torrent.filename': torrentFilename, 'destination.name': dest.name }, async () => { + await dest.triggerImport(this.config.completedPath) + }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.error({ torrentFilename, destination: dest.name, error: message }, 'Failed to trigger import') + } + } + } +} diff --git a/bun.lock b/bun.lock index a2bf90d..d6c83bc 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@opentelemetry/sdk-node": "0.218.0", "@opentelemetry/sdk-trace-base": "2.7.1", "bencode": "^4.0.0", + "drizzle-orm": "^0.45.2", "hono": "4.12.8", "hono-openapi": "1.3.0", "jsonc": "2.0.0", @@ -42,6 +43,7 @@ "@antfu/eslint-config": "^7.7.3", "@total-typescript/ts-reset": "0.6.1", "@types/bun": "1.3.11", + "drizzle-kit": "^0.31.10", "eslint": "10.1.0", "msw": "^2.12.14", "pino-pretty": "^13.1.3", @@ -83,12 +85,70 @@ "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@e18e/eslint-plugin": ["@e18e/eslint-plugin@0.2.0", "", { "dependencies": { "eslint-plugin-depend": "^1.4.0" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0", "oxlint": "^1.41.0" }, "optionalPeers": ["eslint", "oxlint"] }, "sha512-mXgODVwhuDjTJ+UT+XSvmMmCidtGKfrV5nMIv1UtpWex2pYLsIM3RSpT8HWIMAebS9qANbXPKlSX4BE7ZvuCgA=="], "@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.84.0", "", { "dependencies": { "@types/estree": "^1.0.8", "@typescript-eslint/types": "^8.54.0", "comment-parser": "1.4.5", "esquery": "^1.7.0", "jsdoc-type-pratt-parser": "~7.1.1" } }, "sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w=="], "@es-joy/resolve.exports": ["@es-joy/resolve.exports@1.2.0", "", {}, "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@eslint-community/eslint-plugin-eslint-comments": ["@eslint-community/eslint-plugin-eslint-comments@4.7.1", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "ignore": "^7.0.5" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Ql2nJFwA8wUGpILYGOQaT1glPsmvEwE0d+a+l7AALLzQvInqdbXJdx7aSu0DpUX9dB1wMVBMhm99/++S3MdEtQ=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -363,6 +423,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], @@ -445,6 +507,10 @@ "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -459,6 +525,8 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -555,6 +623,8 @@ "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], @@ -893,8 +963,12 @@ "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], @@ -943,6 +1017,8 @@ "ts-declaration-location": ["ts-declaration-location@1.0.7", "", { "dependencies": { "picomatch": "^4.0.2" }, "peerDependencies": { "typescript": ">=4.0.0" } }, "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], @@ -1005,6 +1081,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "@esbuild-kit/esm-loader/get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + "@eslint-community/eslint-plugin-eslint-comments/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -1045,8 +1125,106 @@ "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@jack/schemas/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], } } diff --git a/e2e/tests/auto-registration.test.ts b/e2e/tests/auto-registration.test.ts index 65393a5..cecbc17 100644 --- a/e2e/tests/auto-registration.test.ts +++ b/e2e/tests/auto-registration.test.ts @@ -1,6 +1,6 @@ import type { TestEnv } from '../helpers' import { beforeAll, describe, expect, test } from 'bun:test' -import { fetchJson, getTestEnv } from '../helpers' +import { fetchJson, getTestEnv, retry } from '../helpers' let env: TestEnv @@ -22,29 +22,41 @@ describe('Auto-registration (e2e)', () => { }) test('Jack Beta registered as indexer in Radarr', async () => { - const indexers = await fetchJson }>>( - `${env.radarrUrl}/api/v3/indexer`, - { headers: { 'X-Api-Key': env.radarrApiKey } }, - ) - - const jackIndexer = indexers.find(idx => - idx.fields?.some(f => f.name === 'baseUrl' && String(f.value).includes('jack-beta')), - ) - expect(jackIndexer).toBeDefined() - expect(jackIndexer!.name).toBe('Jack') + const jackIndexer = await retry(async () => { + const indexers = await fetchJson }>>( + `${env.radarrUrl}/api/v3/indexer`, + { headers: { 'X-Api-Key': env.radarrApiKey } }, + ) + + const registered = indexers.find(idx => + idx.fields?.some(f => f.name === 'baseUrl' && String(f.value).includes('jack-beta')), + ) + if (!registered) + throw new Error('Jack Beta indexer is not registered yet') + + return registered + }, { retries: 30, delay: 1_000 }) + + expect(jackIndexer.name).toBe('Jack') }) test('Jack Beta registered as Torrent Blackhole download client in Radarr', async () => { - const clients = await fetchJson }>>( - `${env.radarrUrl}/api/v3/downloadclient`, - { headers: { 'X-Api-Key': env.radarrApiKey } }, - ) - - const jackClient = clients.find(client => - client.fields?.some(f => f.name === 'torrentFolder' && f.value === '/downloads/watch'), - ) - expect(jackClient).toBeDefined() - expect(jackClient!.name).toBe('Jack') - expect(jackClient!.implementation).toBe('TorrentBlackhole') + const jackClient = await retry(async () => { + const clients = await fetchJson }>>( + `${env.radarrUrl}/api/v3/downloadclient`, + { headers: { 'X-Api-Key': env.radarrApiKey } }, + ) + + const registered = clients.find(client => + client.fields?.some(f => f.name === 'torrentFolder' && f.value === '/downloads/watch'), + ) + if (!registered) + throw new Error('Jack Beta download client is not registered yet') + + return registered + }, { retries: 30, delay: 1_000 }) + + expect(jackClient.name).toBe('Jack') + expect(jackClient.implementation).toBe('TorrentBlackhole') }) }) diff --git a/mise-tasks/test/e2e b/mise-tasks/test/e2e index 68c8bbf..0f6085b 100755 --- a/mise-tasks/test/e2e +++ b/mise-tasks/test/e2e @@ -9,6 +9,7 @@ JACK_BETA_API_KEY="beta-test-key" echo "๐Ÿงน Cleaning up previous state..." docker compose -f "$E2E_DIR/docker-compose.yml" down -v 2>/dev/null || true rm -f "$E2E_DIR/config/jack-alpha.jsonc" "$E2E_DIR/config/jack-beta.jsonc" "$E2E_DIR/config/test-env.json" +rm -f "$E2E_DIR/config/database.sqlite" "$E2E_DIR/config/database.sqlite-shm" "$E2E_DIR/config/database.sqlite-wal" rm -rf "$E2E_DIR/volumes" mkdir -p "$E2E_DIR/volumes/blackhole-watch" "$E2E_DIR/volumes/blackhole-completed" mkdir -p "$E2E_DIR/config" @@ -16,15 +17,27 @@ mkdir -p "$E2E_DIR/config" # dirs may be a different uid. Make them world-writable so both can read/write # the blackhole watch/completed folders. chmod 777 "$E2E_DIR/volumes/blackhole-watch" "$E2E_DIR/volumes/blackhole-completed" +# jack also creates database.sqlite next to its mounted config file on startup. +chmod 777 "$E2E_DIR/config" # Same uid mismatch applies to the media fixtures: Radarr (uid 1000) must be able # to read AND write them โ€” its root-folder validation requires the folder to be # writable by its user, so read-only isn't enough. chmod -R a+rwX "$E2E_DIR/fixtures" cleanup() { + exit_code=$? + echo "" + if [ "$exit_code" -ne 0 ]; then + echo "๐Ÿ“‹ Container logs from failed e2e run..." + docker compose -f "$E2E_DIR/docker-compose.yml" logs jack-alpha jack-beta radarr sonarr || true + echo "" + fi + echo "๐Ÿงน Tearing down containers..." docker compose -f "$E2E_DIR/docker-compose.yml" down -v 2>/dev/null || true + + exit "$exit_code" } trap cleanup EXIT