From 84185e1b68f7d93166a66d5143fc561d6dbdc421 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 21:13:22 +0100
Subject: [PATCH 01/18] feat: retry failed downloads with backoff, then remove
them
---
.../src/__tests__/downloads-service.test.ts | 3 +
.../__tests__/failed-download-reaper.test.ts | 115 +++++++++++++
apps/backend/src/index.ts | 23 +++
apps/backend/src/lib/config.ts | 12 ++
.../src/modules/config/config.router.ts | 5 +-
.../src/modules/config/config.service.ts | 5 +-
.../downloads/failed-download-reaper.ts | 153 ++++++++++++++++++
apps/ui/app/components/DownloadsSection.vue | 38 ++++-
apps/ui/app/types/management.ts | 3 +
website/guide/management-ui.md | 4 +-
website/reference/configuration.md | 59 ++++++-
11 files changed, 410 insertions(+), 10 deletions(-)
create mode 100644 apps/backend/src/__tests__/failed-download-reaper.test.ts
create mode 100644 apps/backend/src/modules/downloads/failed-download-reaper.ts
diff --git a/apps/backend/src/__tests__/downloads-service.test.ts b/apps/backend/src/__tests__/downloads-service.test.ts
index 6edadb4..dc016fe 100644
--- a/apps/backend/src/__tests__/downloads-service.test.ts
+++ b/apps/backend/src/__tests__/downloads-service.test.ts
@@ -46,6 +46,9 @@ function downloadsConfig(overrides: Partial> = {}) {
manualImportBackoffBaseMs: 60_000,
manualImportBackoffMaxMs: 1_800_000,
unlinkImportedFiles: false,
+ removeFailedDownloads: false,
+ failedDownloadRetries: 3,
+ failedRetryBaseDelayMs: 60_000,
...overrides,
}
}
diff --git a/apps/backend/src/__tests__/failed-download-reaper.test.ts b/apps/backend/src/__tests__/failed-download-reaper.test.ts
new file mode 100644
index 0000000..da39432
--- /dev/null
+++ b/apps/backend/src/__tests__/failed-download-reaper.test.ts
@@ -0,0 +1,115 @@
+import type { DownloadRecord } from '../modules/downloads/downloads.repository'
+import { expect, test } from 'bun:test'
+import { FailedDownloadReaper } from '../modules/downloads/failed-download-reaper'
+
+function failedRow(overrides: Partial = {}): DownloadRecord {
+ return {
+ id: 1,
+ filename: 'Movie.mkv',
+ status: 'failed',
+ errorCode: 'PEER_UNREACHABLE',
+ operationFailed: true,
+ lastOperation: 'transfer',
+ importMode: null,
+ importTarget: null,
+ ...overrides,
+ } as DownloadRecord
+}
+
+function setup(rows: DownloadRecord[], options: { enabled?: boolean, retryFails?: boolean } = {}) {
+ const clock = { now: 0 }
+ const calls: string[] = []
+ const reaper = new FailedDownloadReaper({
+ enabled: () => options.enabled ?? true,
+ list: () => rows,
+ retry: async (id) => {
+ calls.push(`retry:${id}`)
+ if (options.retryFails)
+ throw new Error('nope')
+ },
+ remove: async (id) => {
+ calls.push(`remove:${id}`)
+ rows.splice(rows.findIndex(row => row.id === id), 1)
+ },
+ maxRetries: 3,
+ baseDelayMs: 100,
+ maxDelayMs: 250,
+ now: () => clock.now,
+ })
+ return { reaper, clock, calls }
+}
+
+test('retries with backoff, then removes the download once retries are spent', async () => {
+ const rows = [
+ failedRow(),
+ ]
+ const { reaper, clock, calls } = setup(rows)
+
+ // Delays: 100, 200, then 400 capped to 250. Every retry fails again (row stays failed).
+ for (const at of [
+ 0,
+ 99,
+ 100,
+ 100,
+ 299,
+ 300,
+ 300,
+ 549,
+ 550,
+ 550,
+ ])
+ await (clock.now = at, reaper.tick())
+
+ expect(calls).toEqual([
+ 'retry:1',
+ 'retry:1',
+ 'retry:1',
+ 'remove:1',
+ ])
+ expect(rows).toHaveLength(0)
+})
+
+test('a retry that throws still counts toward the limit', async () => {
+ const rows = [
+ failedRow(),
+ ]
+ const { reaper, clock, calls } = setup(rows, { retryFails: true })
+ for (let at = 0; at <= 2000; at += 50)
+ await (clock.now = at, reaper.tick())
+ expect(calls).toEqual([
+ 'retry:1',
+ 'retry:1',
+ 'retry:1',
+ 'remove:1',
+ ])
+})
+
+test('removes a failure that cannot be retried right away', async () => {
+ const rows = [
+ failedRow({ lastOperation: 'import', importMode: null }),
+ ]
+ const { reaper, calls } = setup(rows)
+ await reaper.tick()
+ expect(calls).toEqual(['remove:1'])
+})
+
+test('leaves user cancels and other statuses alone', async () => {
+ const rows = [
+ failedRow({ id: 1, errorCode: 'CANCELLED' }),
+ failedRow({ id: 2, status: 'downloading' }),
+ failedRow({ id: 3, status: 'imported' }),
+ ]
+ const { reaper, clock, calls } = setup(rows)
+ for (const at of [0, 1000, 5000])
+ await (clock.now = at, reaper.tick())
+ expect(calls).toEqual([])
+})
+
+test('does nothing while disabled', async () => {
+ const rows = [
+ failedRow({ lastOperation: 'import' }),
+ ]
+ const { reaper, calls } = setup(rows, { enabled: false })
+ await reaper.tick()
+ expect(calls).toEqual([])
+})
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index 7fabe33..5867120 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -16,8 +16,10 @@ import { CatalogSyncService } from './modules/catalog/catalog-sync.service'
import { OwnCatalogRepository } from './modules/catalog/own-catalog.repository'
import { TmdbMetadataRepository } from './modules/catalog/tmdb-metadata.repository'
import { ConfigService } from './modules/config/config.service'
+import { DownloadsManagementController } from './modules/downloads/downloads.controller'
import { DownloadsRepository } from './modules/downloads/downloads.repository'
import { DownloadsService } from './modules/downloads/downloads.service'
+import { FailedDownloadReaper } from './modules/downloads/failed-download-reaper'
import { ImportWatcher } from './modules/downloads/import-watcher'
import { ManagedKeysRepository } from './modules/managed-keys/managed-keys.repository'
import { ManagedApiKeys } from './modules/managed-keys/managed-keys.service'
@@ -120,6 +122,23 @@ const importWatcher = downloadsConfig
})
: undefined
+// Retries failed downloads with backoff, then deletes the ones that keep failing.
+// Routed through the management controller so transfers and imports each retry the
+// way the ***Retry*** button does.
+const downloadsController = new DownloadsManagementController(downloadsRepository, downloadsService, importWatcher)
+const failedDownloadReaper = downloadsConfig
+ ? new FailedDownloadReaper({
+ // Read per tick, like unlinkImportedFiles, so the UI toggle applies live.
+ enabled: () => configService?.getRawDownloads()?.removeFailedDownloads ?? downloadsConfig.removeFailedDownloads,
+ list: () => downloadsRepository.list(),
+ retry: id => downloadsController.retry(id),
+ remove: id => downloadsController.delete(id),
+ maxRetries: downloadsConfig.failedDownloadRetries,
+ baseDelayMs: downloadsConfig.failedRetryBaseDelayMs,
+ maxDelayMs: downloadsConfig.retryMaxDelayMs,
+ })
+ : undefined
+
function startManagementServer() {
if (!envs.MANAGEMENT_KEY)
return undefined
@@ -185,6 +204,8 @@ for (const dest of registrable) {
managedApiKeys.prune(registrable.map(d => d.id))
importWatcher?.start()
+if (downloadsConfig)
+ failedDownloadReaper?.start(downloadsConfig.importPollIntervalMs)
// Re-drive interrupted downloads from a prior run.
if (config.downloads && downloadsService) {
@@ -204,6 +225,7 @@ else {
process.on('SIGINT', async () => {
logger.info('SIGINT received, exiting')
importWatcher?.stop()
+ failedDownloadReaper?.stop()
catalogSync.stop()
database.close()
server.stop()
@@ -215,6 +237,7 @@ process.on('SIGINT', async () => {
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, exiting')
importWatcher?.stop()
+ failedDownloadReaper?.stop()
catalogSync.stop()
database.close()
server.stop()
diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts
index 176a83f..7c3cbfc 100644
--- a/apps/backend/src/lib/config.ts
+++ b/apps/backend/src/lib/config.ts
@@ -276,6 +276,15 @@ export const DownloadsConfig = z.object({
// library's link) or copied it. Enabling this unlinks jack's directory entry —
// never a recursive delete — right after a row flips to `imported`.
unlinkImportedFiles: z.boolean().default(false),
+ // When enabled, a download that ends up `failed` (a transfer or import whose own
+ // retries above are spent, or a permanent error) is retried as a whole up to
+ // `failedDownloadRetries` times, backing off exponentially from
+ // `failedRetryBaseDelayMs` (capped at `retryMaxDelayMs`). If it still fails, its
+ // row and any partial or completed file are deleted. User cancels are left alone.
+ removeFailedDownloads: z.boolean().default(false),
+ // At least 3: network errors are often only reported once per attempt.
+ failedDownloadRetries: z.number().int().min(3).default(3),
+ failedRetryBaseDelayMs: z.number().int().min(0).default(60_000),
})
export type DownloadsConfig = z.infer
@@ -300,6 +309,9 @@ export const RawDownloadsConfig = z.object({
manualImportBackoffBaseMs: z.number().int().min(0).nullish(),
manualImportBackoffMaxMs: z.number().int().min(0).nullish(),
unlinkImportedFiles: z.boolean().nullish(),
+ removeFailedDownloads: z.boolean().nullish(),
+ failedDownloadRetries: z.number().int().min(3).nullish(),
+ failedRetryBaseDelayMs: z.number().int().min(0).nullish(),
})
export type RawDownloadsConfig = z.infer
diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts
index 2eb18c0..ed7abe0 100644
--- a/apps/backend/src/modules/config/config.router.ts
+++ b/apps/backend/src/modules/config/config.router.ts
@@ -96,8 +96,9 @@ export function getConfigRouter(controller: ConfigController) {
})
// Partial patch: the body is merged onto the stored downloads block. Every knob
- // but `unlinkImportedFiles` is captured at boot, so those land on next restart.
- app.patch('/downloads', configDoc('Update the downloads block', 'Merges the given fields into the stored downloads config. `unlinkImportedFiles` applies immediately; the other values take effect on next boot.'), zValidator('json', RawDownloadsConfig), async (c) => {
+ // but `unlinkImportedFiles` and `removeFailedDownloads` is captured at boot, so
+ // those take effect on next restart.
+ app.patch('/downloads', configDoc('Update the downloads block', 'Merges the given fields into the stored downloads config. `unlinkImportedFiles` and `removeFailedDownloads` apply immediately; the other values take effect on next boot.'), zValidator('json', RawDownloadsConfig), async (c) => {
return c.json(await controller.updateDownloads(c.req.valid('json')))
})
diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts
index 70609b3..3d14dc4 100644
--- a/apps/backend/src/modules/config/config.service.ts
+++ b/apps/backend/src/modules/config/config.service.ts
@@ -376,8 +376,9 @@ export class ConfigService {
* this is what the UI's empty field means. `undefined` (absent) leaves the stored
* value alone.
*
- * Only `unlinkImportedFiles` takes effect immediately (the import watcher reads it
- * per import); the rest are captured at boot and land on the next restart.
+ * Only `unlinkImportedFiles` and `removeFailedDownloads` take effect immediately
+ * (the import watcher and the failed-download reaper read them as they run); the
+ * rest are captured at boot and apply on the next restart.
*/
async updateDownloads(input: unknown): Promise {
const patch = RawDownloadsConfig.parse(input)
diff --git a/apps/backend/src/modules/downloads/failed-download-reaper.ts b/apps/backend/src/modules/downloads/failed-download-reaper.ts
new file mode 100644
index 0000000..a39977d
--- /dev/null
+++ b/apps/backend/src/modules/downloads/failed-download-reaper.ts
@@ -0,0 +1,153 @@
+import type { DownloadRecord } from './downloads.repository'
+import { logger } from '../../logger'
+
+export interface FailedDownloadReaperOptions {
+ /** Read every tick, so toggling `downloads.removeFailedDownloads` applies live. */
+ enabled: () => boolean
+ list: () => DownloadRecord[]
+ /** Repeats the row's last failed operation (transfer or import). */
+ retry: (id: number) => Promise
+ /** Deletes the row and its unshared partial or completed files. */
+ remove: (id: number) => Promise
+ maxRetries: number
+ baseDelayMs: number
+ maxDelayMs: number
+ /** Injectable for tests. */
+ now?: () => number
+}
+
+/** Same rule the UI uses to offer ***Retry***: only these failures can be repeated. */
+function canRetry(row: DownloadRecord): boolean {
+ if (!row.operationFailed)
+ return false
+ if (row.lastOperation === 'transfer')
+ return true
+ return row.lastOperation === 'import' && row.importMode === 'jack_manual' && row.importTarget != null
+}
+
+/**
+ * Retries failed downloads with exponential backoff and deletes the ones that keep
+ * failing. Each tick, a failed row either waits out its backoff, gets retried, or
+ * (retries spent, or not retryable) is removed with its files.
+ *
+ * Retry counts live in memory: a restart starts every row's count over.
+ */
+export class FailedDownloadReaper {
+ // `nextAt` is unset right after a retry fires; the next tick that sees the row
+ // failed again schedules the following retry from that moment.
+ private readonly state = new Map()
+ private timer?: ReturnType
+ private tickInFlight?: Promise
+
+ constructor(private readonly options: FailedDownloadReaperOptions) {}
+
+ private delayFor(retries: number): number {
+ return Math.min(this.options.maxDelayMs, this.options.baseDelayMs * 2 ** retries)
+ }
+
+ start(intervalMs: number): void {
+ if (this.timer)
+ return
+ this.timer = setInterval(() => {
+ void this.tick().catch((err) => {
+ logger.warn({
+ error: err instanceof Error
+ ? err.message
+ : String(err),
+ }, 'Failed download reaper tick failed')
+ })
+ }, intervalMs)
+ }
+
+ stop(): void {
+ clearInterval(this.timer)
+ this.timer = undefined
+ }
+
+ tick(): Promise {
+ // One pass at a time: a slow delete must not overlap the next interval.
+ this.tickInFlight ??= this.run().finally(() => {
+ this.tickInFlight = undefined
+ })
+ return this.tickInFlight
+ }
+
+ private async run(): Promise {
+ if (!this.options.enabled())
+ return
+ const now = (this.options.now ?? Date.now)()
+ const rows = this.options.list()
+
+ // Forget rows that are gone or finished, so their ids don't keep stale counts.
+ const live = new Map(rows.map(row => [row.id, row]))
+ for (const id of this.state.keys()) {
+ const status = live.get(id)?.status
+ if (status === undefined || status === 'imported')
+ this.state.delete(id)
+ }
+
+ for (const row of rows) {
+ // A user cancel keeps its .part on purpose; leave it for them.
+ if (row.status !== 'failed' || row.errorCode === 'CANCELLED')
+ continue
+
+ const entry = this.state.get(row.id) ?? { retries: 0 }
+ this.state.set(row.id, entry)
+
+ if (entry.nextAt === undefined) {
+ if (entry.retries >= this.options.maxRetries || !canRetry(row)) {
+ await this.removeRow(row, entry.retries)
+ continue
+ }
+ entry.nextAt = now + this.delayFor(entry.retries)
+ continue
+ }
+ if (now < entry.nextAt)
+ continue
+
+ entry.retries++
+ entry.nextAt = undefined
+ try {
+ await this.options.retry(row.id)
+ logger.info({
+ id: row.id,
+ filename: row.filename,
+ retry: entry.retries,
+ maxRetries: this.options.maxRetries,
+ }, 'Retrying failed download')
+ }
+ catch (err) {
+ // Counts as a spent retry; the next tick schedules the following one.
+ logger.warn({
+ id: row.id,
+ retry: entry.retries,
+ error: err instanceof Error
+ ? err.message
+ : String(err),
+ }, 'Could not retry failed download')
+ }
+ }
+ }
+
+ private async removeRow(row: DownloadRecord, retries: number): Promise {
+ try {
+ await this.options.remove(row.id)
+ this.state.delete(row.id)
+ logger.info({
+ id: row.id,
+ filename: row.filename,
+ retries,
+ error_code: row.errorCode,
+ }, 'Removed failed download')
+ }
+ catch (err) {
+ // State is kept, so the next tick tries the removal again.
+ logger.warn({
+ id: row.id,
+ error: err instanceof Error
+ ? err.message
+ : String(err),
+ }, 'Could not remove failed download')
+ }
+ }
+}
diff --git a/apps/ui/app/components/DownloadsSection.vue b/apps/ui/app/components/DownloadsSection.vue
index 7c178c7..140e574 100644
--- a/apps/ui/app/components/DownloadsSection.vue
+++ b/apps/ui/app/components/DownloadsSection.vue
@@ -27,6 +27,9 @@ function blankState() {
manualImportBackoffBaseMs: null as number | null,
manualImportBackoffMaxMs: null as number | null,
unlinkImportedFiles: false,
+ removeFailedDownloads: false,
+ failedDownloadRetries: null as number | null,
+ failedRetryBaseDelayMs: null as number | null,
}
}
@@ -99,6 +102,9 @@ async function save() {
manualImportBackoffMaxMs: state.manualImportBackoffMaxMs,
// Off is the default, so write it as an absent key rather than an explicit false.
unlinkImportedFiles: state.unlinkImportedFiles ? true : null,
+ removeFailedDownloads: state.removeFailedDownloads ? true : null,
+ failedDownloadRetries: state.failedDownloadRetries,
+ failedRetryBaseDelayMs: state.failedRetryBaseDelayMs,
}
const creating = !downloads.value
try {
@@ -133,6 +139,8 @@ const transferKeys = [
'idleTimeoutMs',
'retryBaseDelayMs',
'retryMaxDelayMs',
+ 'failedDownloadRetries',
+ 'failedRetryBaseDelayMs',
] as const
const importKeys = [
'importPollIntervalMs',
@@ -151,6 +159,8 @@ const DEFAULTS: Record = {
maxManualImportAttempts: 6,
manualImportBackoffBaseMs: 60_000,
manualImportBackoffMaxMs: 1_800_000,
+ failedDownloadRetries: 3,
+ failedRetryBaseDelayMs: 60_000,
}
function customCount(keys: readonly string[]) {
@@ -218,6 +228,14 @@ const importOpen = ref(false)
>
+
+
+
+
+
+ {{ selected.length }} selected
+
+
+
+
[] = [
:meta="{ class: { tr: 'cursor-pointer' } }"
:ui="{ td: 'align-top' }"
>
+
+ table.toggleAllPageRowsSelected(value === true)"
+ />
+
+
+
+ row.toggleSelected(value === true)"
+ />
+
+
[] = [
-
+
+
{{ d.filename }}
From e48df7bd1a2c7ea91b1f9f8fce9e410975aa4870 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 21:14:15 +0100
Subject: [PATCH 04/18] feat(ui): show the version in the sidebar and link the
repository in About
---
apps/ui/app/layouts/default.vue | 36 ++++++++++++++++++---------------
apps/ui/app/pages/settings.vue | 9 +++++++++
2 files changed, 29 insertions(+), 16 deletions(-)
diff --git a/apps/ui/app/layouts/default.vue b/apps/ui/app/layouts/default.vue
index a35dd8a..8c4efb5 100644
--- a/apps/ui/app/layouts/default.vue
+++ b/apps/ui/app/layouts/default.vue
@@ -2,6 +2,7 @@
import type { NavigationMenuItem } from '@nuxt/ui'
const { state, logout } = useAuth()
+const version = useRuntimeConfig().public.appVersion
// Peers and *arr servers are managed as sections under Settings, so the nav is
// just the three live views plus configuration.
@@ -46,22 +47,25 @@ async function onLogout() {
-
-
-
- Key injected by host
-
-
+
+
v{{ version }}
+
+
+
+ Key injected by host
+
+
+
diff --git a/apps/ui/app/pages/settings.vue b/apps/ui/app/pages/settings.vue
index 96b6984..f237148 100644
--- a/apps/ui/app/pages/settings.vue
+++ b/apps/ui/app/pages/settings.vue
@@ -374,6 +374,15 @@ async function confirmRevoke() {
Self-hosted *arr peer bridge
+
+
+ roziscoding/jack
+
From 00393850f8d63701700aeb3fcf7da009c7be4d7a Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 22:08:36 +0100
Subject: [PATCH 05/18] fix(ui): show the release version instead of the
package.json one
---
.github/workflows/publish.yml | 3 +++
apps/ui/Dockerfile | 3 +++
apps/ui/app/layouts/default.vue | 2 +-
apps/ui/app/pages/settings.vue | 2 +-
apps/ui/app/utils/format.test.ts | 8 +++++++-
apps/ui/app/utils/format.ts | 7 +++++++
apps/ui/nuxt.config.ts | 26 ++++++++++++++++++++++----
7 files changed, 44 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 04856c6..9c531a2 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -83,6 +83,9 @@ jobs:
push: true
tags: ${{ steps.meta-ui.outputs.tags }}
labels: ${{ steps.meta-ui.outputs.labels }}
+ # Shown in the UI: `1.2.3` on a v* tag, `main` or `pr-` otherwise.
+ build-args: |
+ APP_VERSION=${{ steps.meta-ui.outputs.version }}
cache-from: type=gha,scope=ui
cache-to: type=gha,mode=max,scope=ui
diff --git a/apps/ui/Dockerfile b/apps/ui/Dockerfile
index 98dbea2..254bf88 100644
--- a/apps/ui/Dockerfile
+++ b/apps/ui/Dockerfile
@@ -28,6 +28,9 @@ CMD ["bun", "run", "--cwd", "apps/ui", "dev", "--host", "0.0.0.0", "--port", "30
# ---- build: the Nuxt/Nitro server ----
FROM development AS build
+# The version shown in the UI (CI passes the image version). Unset falls back to
+# `dev`, since the build context has no .git to read tags from.
+ARG APP_VERSION
RUN bun run --cwd apps/ui build
# ---- runtime: the self-contained Nitro output ----
diff --git a/apps/ui/app/layouts/default.vue b/apps/ui/app/layouts/default.vue
index 8c4efb5..f9565e5 100644
--- a/apps/ui/app/layouts/default.vue
+++ b/apps/ui/app/layouts/default.vue
@@ -48,7 +48,7 @@ async function onLogout() {
-
v{{ version }}
+
{{ formatVersion(version) }}
jack
- v{{ version }}
+ {{ formatVersion(version) }}
Self-hosted *arr peer bridge
diff --git a/apps/ui/app/utils/format.test.ts b/apps/ui/app/utils/format.test.ts
index ee84cf2..bfbf374 100644
--- a/apps/ui/app/utils/format.test.ts
+++ b/apps/ui/app/utils/format.test.ts
@@ -1,5 +1,11 @@
import { describe, expect, test } from 'bun:test'
-import { formatDurationMs } from './format'
+import { formatDurationMs, formatVersion } from './format'
+
+test('formatVersion prefixes only release versions', () => {
+ expect(formatVersion('0.5.0')).toBe('v0.5.0')
+ expect(formatVersion('main')).toBe('main')
+ expect(formatVersion('pr-12')).toBe('pr-12')
+})
// Settings shows raw milliseconds (that's what the config file holds) next to this
// reading of them, so the numbers stay editable without being unreadable.
diff --git a/apps/ui/app/utils/format.ts b/apps/ui/app/utils/format.ts
index ebe3082..019d3fa 100644
--- a/apps/ui/app/utils/format.ts
+++ b/apps/ui/app/utils/format.ts
@@ -1,3 +1,10 @@
+/** `1.2.3` -> `v1.2.3`; a branch or PR build (`main`, `pr-12`, `dev`) is shown as is. */
+export function formatVersion(version: string): string {
+ return /^\d/.test(version)
+ ? `v${version}`
+ : version
+}
+
export function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || Number.isNaN(bytes))
return '—'
diff --git a/apps/ui/nuxt.config.ts b/apps/ui/nuxt.config.ts
index 73aa2f3..ba0d629 100644
--- a/apps/ui/nuxt.config.ts
+++ b/apps/ui/nuxt.config.ts
@@ -1,5 +1,23 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
-import pkg from './package.json'
+import { execSync } from 'node:child_process'
+import process from 'node:process'
+
+// Releases are cut from git tags; package.json is never bumped. CI passes the
+// image version as APP_VERSION (`1.2.3`, `main`, `pr-12`); a local build falls
+// back to the latest tag, then to `dev` (the Docker build context has no .git).
+function appVersion(): string {
+ const fromEnv = process.env.APP_VERSION?.trim()
+ if (fromEnv)
+ return fromEnv
+ try {
+ return execSync('git describe --tags --abbrev=0', {
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).toString().trim().replace(/^v/, '')
+ }
+ catch {
+ return 'dev'
+ }
+}
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
@@ -47,10 +65,10 @@ export default defineNuxtConfig({
// (>= 32 chars) in production; the default is a clearly-insecure dev value.
sessionKey: 'dev-insecure-session-key-change-me-please-1234',
- // Browser-exposed: the app version, baked from package.json at build time and
- // shown in Settings → About.
+ // Browser-exposed: the app version, baked in at build time (see appVersion
+ // above) and shown in the sidebar and Settings → About.
public: {
- appVersion: pkg.version,
+ appVersion: appVersion(),
},
},
})
From 033e03c2b1cae8b83b18fcb4f7fc70d905e8ca15 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 22:11:03 +0100
Subject: [PATCH 06/18] feat: stream live download progress, speed and ETA
---
.github/workflows/publish.yml | 3 +-
.../src/__tests__/live-progress.test.ts | 48 ++++++++++
apps/backend/src/lib/servers/peer.ts | 9 ++
.../modules/downloads/downloads.service.ts | 10 ++
.../src/modules/downloads/live-progress.ts | 92 +++++++++++++++++++
.../src/modules/status/status.controller.ts | 49 ++++++++--
apps/ui/app/pages/downloads.vue | 6 ++
apps/ui/app/pages/index.vue | 3 +
apps/ui/app/types/management.ts | 3 +
apps/ui/app/utils/format.ts | 10 ++
apps/ui/nuxt.config.ts | 3 +-
11 files changed, 226 insertions(+), 10 deletions(-)
create mode 100644 apps/backend/src/__tests__/live-progress.test.ts
create mode 100644 apps/backend/src/modules/downloads/live-progress.ts
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 9c531a2..8005330 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -83,7 +83,8 @@ jobs:
push: true
tags: ${{ steps.meta-ui.outputs.tags }}
labels: ${{ steps.meta-ui.outputs.labels }}
- # Shown in the UI: `1.2.3` on a v* tag, `main` or `pr-` otherwise.
+ # Shown in the UI. Same value as the image's org.opencontainers.image.version
+ # label: `1.2.3` on a v* tag, `main` or `pr-` otherwise.
build-args: |
APP_VERSION=${{ steps.meta-ui.outputs.version }}
cache-from: type=gha,scope=ui
diff --git a/apps/backend/src/__tests__/live-progress.test.ts b/apps/backend/src/__tests__/live-progress.test.ts
new file mode 100644
index 0000000..b3e1cd1
--- /dev/null
+++ b/apps/backend/src/__tests__/live-progress.test.ts
@@ -0,0 +1,48 @@
+import { expect, test } from 'bun:test'
+import { LiveProgressTracker } from '../modules/downloads/live-progress'
+import { enrichDownload } from '../modules/status/status.controller'
+
+test('speed is averaged over the window and resets when bytes go backwards', () => {
+ const clock = { now: 0 }
+ const live = new LiveProgressTracker(() => clock.now)
+
+ live.record(1, 0)
+ expect(live.get(1)).toEqual({ downloadedBytes: 0, speedBps: null })
+
+ clock.now = 2000
+ live.record(1, 4000)
+ expect(live.get(1)).toEqual({ downloadedBytes: 4000, speedBps: 2000 })
+
+ // A restart drops the window instead of reporting a negative speed.
+ clock.now = 3000
+ live.record(1, 100)
+ expect(live.get(1)).toEqual({ downloadedBytes: 100, speedBps: null })
+
+ live.clear(1)
+ expect(live.get(1)).toBeNull()
+})
+
+test('enrichDownload overlays live bytes, speed and ETA on a running transfer', () => {
+ const clock = { now: 0 }
+ const live = new LiveProgressTracker(() => clock.now)
+ live.record(7, 1000)
+ clock.now = 1000
+ live.record(7, 3000)
+
+ const row = {
+ id: 7,
+ status: 'downloading',
+ downloadedBytes: 500,
+ expectedBytes: 10_000,
+ releaseSize: 10_000,
+ } as Parameters[0]
+ expect(enrichDownload(row, live)).toMatchObject({
+ downloadedBytes: 3000,
+ progress: 0.3,
+ speedBps: 2000,
+ etaSeconds: 4,
+ })
+
+ // Finished rows ignore whatever the tracker still holds.
+ expect(enrichDownload({ ...row, status: 'import_queued' }, live)).toMatchObject({ downloadedBytes: 500, speedBps: null, etaSeconds: null })
+})
diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts
index 818b28e..4bedcd0 100644
--- a/apps/backend/src/lib/servers/peer.ts
+++ b/apps/backend/src/lib/servers/peer.ts
@@ -22,11 +22,14 @@ const PeerSearchResponse = z.object({
const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 * 1024 // 100GB
const DOWNLOAD_PROGRESS_INTERVAL_MS = 10_000
const DOWNLOAD_PROGRESS_BYTES = 64 * 1024 * 1024
+const DOWNLOAD_LIVE_INTERVAL_MS = 1000
const CONTENT_RANGE_PATTERN = /^bytes (\d+)-(\d+)\/(\d+)$/
export type PeerDownloadProgressEvent
= | { type: 'headers', expectedBytes: number | null, expectedBytesSource: 'content_length' | 'content_range' | 'release_size' | null, expectedBytesMismatch: boolean }
| { type: 'progress', downloadedBytes: number, expectedBytes: number | null }
+ // About once a second, for live progress only; `progress` is the durable one.
+ | { type: 'bytes', downloadedBytes: number }
| { type: 'restart', reason: 'range_ignored' | 'content_range_mismatch' | 'range_not_satisfiable' | 'part_oversize', discardedBytes: number }
| { type: 'completed', downloadedBytes: number, expectedBytes: number | null }
@@ -498,6 +501,7 @@ export class PeerConnector extends ServerConnector {
let downloadedBytes = existingBytes
let lastLoggedAt = Date.now()
let lastLoggedBytes = downloadedBytes
+ let lastLiveAt = 0
let handleClosed = false
const closeHandle = async () => {
@@ -565,6 +569,11 @@ export class PeerConnector extends ServerConnector {
lastLoggedAt = now
lastLoggedBytes = downloadedBytes
}
+ // Cheap in-memory tick for live progress and speed: no fsync, no DB write.
+ if (now - lastLiveAt >= DOWNLOAD_LIVE_INTERVAL_MS) {
+ await options.onProgress?.({ type: 'bytes', downloadedBytes })
+ lastLiveAt = now
+ }
}
await syncPart(`flushing the .part to disk after the last chunk (${downloadedBytes} bytes written)`)
diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts
index ed33cc4..00e596a 100644
--- a/apps/backend/src/modules/downloads/downloads.service.ts
+++ b/apps/backend/src/modules/downloads/downloads.service.ts
@@ -14,6 +14,7 @@ import { logger } from '../../logger'
import { unlinkDownloadArtifact } from './artifact-cleanup'
import { transferErrorCode, transferErrorLogFields } from './download-error-codes'
import { coordinatorFor } from './download-operation-coordinator'
+import { liveProgressFor } from './live-progress'
import { downloadRetryAfterMs, isTransientDownloadError } from './retry-policy'
type DownloadsServiceConfig = NonNullable
@@ -374,11 +375,14 @@ export class DownloadsService {
}
finally {
this.active.delete(record.destPath)
+ if (this.downloadsRepository)
+ liveProgressFor(this.downloadsRepository).clear(record.id)
}
}
private async downloadWithRetry(record: DownloadRecord, signal: AbortSignal): Promise {
const repo = this.downloadsRepository
+ const live = repo && liveProgressFor(repo)
const peer = this.peers.find(p => p.id === record.peerId)
if (!peer) {
repo?.markFailed(record.id, 'PEER_NOT_FOUND', `Peer ${record.peerId} not found`)
@@ -391,12 +395,18 @@ export class DownloadsService {
repo?.setExpectedBytes(record.id, event.expectedBytes, event.expectedBytesSource, event.expectedBytesMismatch)
return
}
+ if (event.type === 'bytes') {
+ live?.record(record.id, event.downloadedBytes)
+ return
+ }
if (event.type === 'progress') {
repo?.updateProgress(record.id, event.downloadedBytes)
+ live?.record(record.id, event.downloadedBytes)
return
}
if (event.type === 'restart') {
repo?.markResumeReset(record.id)
+ live?.clear(record.id)
return
}
// event.type === 'completed': the transfer finished. Record the final byte
diff --git a/apps/backend/src/modules/downloads/live-progress.ts b/apps/backend/src/modules/downloads/live-progress.ts
new file mode 100644
index 0000000..ef4179f
--- /dev/null
+++ b/apps/backend/src/modules/downloads/live-progress.ts
@@ -0,0 +1,92 @@
+import type { DownloadsRepository } from './downloads.repository'
+
+// Speed is averaged over this much recent history.
+const SPEED_WINDOW_MS = 10_000
+// Live updates fire at most this often, so the SSE stream (a full snapshot per
+// notify) stays at about one frame per second however many transfers run.
+const NOTIFY_INTERVAL_MS = 1000
+
+export interface LiveProgress {
+ downloadedBytes: number
+ /** Bytes per second over the recent window, or null until two samples exist. */
+ speedBps: number | null
+}
+
+/**
+ * In-memory byte counts for running transfers, fed about once a second by the
+ * peer download loop. The database only stores progress every 10 s / 64 MiB
+ * (each write also fsyncs the .part), so this is what lets the UI move the bar
+ * and show a speed in between. Nothing here is persisted.
+ */
+export class LiveProgressTracker {
+ private readonly samples = new Map()
+ private readonly subscribers = new Set<() => void>()
+ private notifyTimer?: ReturnType
+
+ constructor(private readonly now: () => number = Date.now) {}
+
+ record(id: number, bytes: number): void {
+ const t = this.now()
+ const kept = (this.samples.get(id) ?? []).filter(s => t - s.t <= SPEED_WINDOW_MS)
+ // Fewer bytes than before means the transfer started over (restart or retry).
+ const window = kept.length > 0 && bytes < kept.at(-1)!.bytes
+ ? []
+ : kept
+ window.push({ t, bytes })
+ this.samples.set(id, window)
+ this.scheduleNotify()
+ }
+
+ clear(id: number): void {
+ if (this.samples.delete(id))
+ this.scheduleNotify()
+ }
+
+ get(id: number): LiveProgress | null {
+ const window = this.samples.get(id)
+ if (!window?.length)
+ return null
+ const first = window[0]!
+ const last = window.at(-1)!
+ const elapsedMs = last.t - first.t
+ return {
+ downloadedBytes: last.bytes,
+ speedBps: elapsedMs > 0
+ ? ((last.bytes - first.bytes) * 1000) / elapsedMs
+ : null,
+ }
+ }
+
+ subscribe(subscriber: () => void): () => void {
+ this.subscribers.add(subscriber)
+ return () => this.subscribers.delete(subscriber)
+ }
+
+ private scheduleNotify(): void {
+ if (this.notifyTimer)
+ return
+ this.notifyTimer = setTimeout(() => {
+ this.notifyTimer = undefined
+ for (const subscriber of this.subscribers) {
+ try {
+ subscriber()
+ }
+ catch {
+ // A broken observer must not stop the transfer that fed it.
+ }
+ }
+ }, NOTIFY_INTERVAL_MS)
+ }
+}
+
+const trackers = new WeakMap()
+
+/** One tracker per repository, shared by the service (writer) and status API (reader). */
+export function liveProgressFor(repository: DownloadsRepository): LiveProgressTracker {
+ let tracker = trackers.get(repository)
+ if (!tracker) {
+ tracker = new LiveProgressTracker()
+ trackers.set(repository, tracker)
+ }
+ return tracker
+}
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 2c3cdbe..ab0310c 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -3,7 +3,9 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base'
import type { ServerConnector } from '../../lib/servers/base'
import type { PeerConnector } from '../../lib/servers/peer'
import type { DownloadRecord, DownloadsRepository } from '../downloads/downloads.repository'
+import type { LiveProgressTracker } from '../downloads/live-progress'
import { DOWNLOAD_STATUSES } from '../../database/schema'
+import { liveProgressFor } from '../downloads/live-progress'
// The management surface can expose richer detail than the public API: every
// connector here carries its URL-derived `id` (the management UI keys edit/delete
@@ -25,15 +27,32 @@ function baseConnector(c: ServerConnector) {
}
}
-export function enrichDownload(d: DownloadRecord) {
+export function enrichDownload(d: DownloadRecord, live?: LiveProgressTracker) {
// Prefer the negotiated expected size; fall back to the release-advertised size.
const totalBytes = d.expectedBytes ?? (d.releaseSize > 0
? d.releaseSize
: null)
+ // A running transfer reports bytes about once a second in memory; the row is only
+ // written every 10 s / 64 MiB, so prefer the live count while it's downloading.
+ const current = d.status === 'downloading'
+ ? live?.get(d.id)
+ : null
+ const downloadedBytes = Math.max(d.downloadedBytes, current?.downloadedBytes ?? 0)
const progress = totalBytes && totalBytes > 0
- ? Math.min(1, d.downloadedBytes / totalBytes)
+ ? Math.min(1, downloadedBytes / totalBytes)
+ : null
+ const speedBps = current?.speedBps ?? null
+ const etaSeconds = speedBps && speedBps > 0 && totalBytes
+ ? Math.max(0, Math.round((totalBytes - downloadedBytes) / speedBps))
: null
- return { ...d, totalBytes, progress }
+ return {
+ ...d,
+ downloadedBytes,
+ totalBytes,
+ progress,
+ speedBps,
+ etaSeconds,
+ }
}
export class StatusController {
@@ -42,10 +61,17 @@ export class StatusController {
private readonly downloads?: DownloadsRepository,
) {}
+ private get live(): LiveProgressTracker | undefined {
+ return this.downloads && liveProgressFor(this.downloads)
+ }
+
+ private enrich = (d: DownloadRecord) => enrichDownload(d, this.live)
+
subscribeOverview(subscriber: () => void): () => void {
const unsubscribers = [
this.connectors.subscribe?.(subscriber),
this.downloads?.subscribe(subscriber),
+ this.live?.subscribe(subscriber),
].filter((unsubscribe): unsubscribe is () => void => Boolean(unsubscribe))
return () => {
for (const unsubscribe of unsubscribers)
@@ -54,7 +80,14 @@ export class StatusController {
}
subscribeDownloads(subscriber: () => void): () => void {
- return this.downloads?.subscribe(subscriber) ?? (() => {})
+ const unsubscribers = [
+ this.downloads?.subscribe(subscriber),
+ this.live?.subscribe(subscriber),
+ ].filter((unsubscribe): unsubscribe is () => void => Boolean(unsubscribe))
+ return () => {
+ for (const unsubscribe of unsubscribers)
+ unsubscribe()
+ }
}
/** Whether download-backed endpoints are available (a repository was injected). */
@@ -107,9 +140,9 @@ export class StatusController {
bytesMoved,
mismatched,
// `records` is sorted by updatedAt desc, so each slice is the most recent.
- active: records.filter(r => r.status === 'downloading').map(enrichDownload),
- importQueued: records.filter(r => r.status === 'import_queued').slice(0, ATTENTION_LIMIT).map(enrichDownload),
- failed: records.filter(r => r.status === 'failed').slice(0, ATTENTION_LIMIT).map(enrichDownload),
+ active: records.filter(r => r.status === 'downloading').map(this.enrich),
+ importQueued: records.filter(r => r.status === 'import_queued').slice(0, ATTENTION_LIMIT).map(this.enrich),
+ failed: records.filter(r => r.status === 'failed').slice(0, ATTENTION_LIMIT).map(this.enrich),
},
}
}
@@ -117,7 +150,7 @@ export class StatusController {
listDownloads() {
const records = this.downloads?.list() ?? []
return {
- downloads: records.map(enrichDownload),
+ downloads: records.map(this.enrich),
}
}
}
diff --git a/apps/ui/app/pages/downloads.vue b/apps/ui/app/pages/downloads.vue
index 50d6401..4d8c14b 100644
--- a/apps/ui/app/pages/downloads.vue
+++ b/apps/ui/app/pages/downloads.vue
@@ -511,6 +511,9 @@ const columns: TableColumn[] = [
+
+ {{ formatTransferRate(row.original.speedBps, row.original.etaSeconds) }}
+
@@ -563,6 +566,9 @@ const columns: TableColumn[] = [
+
+ {{ formatTransferRate(d.speedBps, d.etaSeconds) }}
+
{{ d.peerName }}
{{ formatBytes(d.totalBytes) }} · {{ formatAgo(d.updatedAt) }} ago
diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue
index 095b8bf..7e9e453 100644
--- a/apps/ui/app/pages/index.vue
+++ b/apps/ui/app/pages/index.vue
@@ -111,6 +111,9 @@ function serverRole(server: { source: boolean, destination: boolean }) {
{{ formatBytes(d.downloadedBytes) }} / {{ formatBytes(d.totalBytes) }}
+
+ {{ formatTransferRate(d.speedBps, d.etaSeconds) }}
+
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts
index d04622c..2da8658 100644
--- a/apps/ui/app/types/management.ts
+++ b/apps/ui/app/types/management.ts
@@ -59,6 +59,9 @@ export interface DownloadItem {
downloadedBytes: number
totalBytes: number | null
progress: number | null
+ // Live, while downloading: averaged over the last few seconds. Null otherwise.
+ speedBps: number | null
+ etaSeconds: number | null
releaseSize: number
attempts: number
error: string | null
diff --git a/apps/ui/app/utils/format.ts b/apps/ui/app/utils/format.ts
index 019d3fa..2b0402b 100644
--- a/apps/ui/app/utils/format.ts
+++ b/apps/ui/app/utils/format.ts
@@ -1,3 +1,13 @@
+/** "12.3 MB/s · 4 min left" for a running transfer; empty when there's no live speed yet. */
+export function formatTransferRate(speedBps: number | null, etaSeconds: number | null): string {
+ if (speedBps == null)
+ return ''
+ const rate = `${formatBytes(speedBps)}/s`
+ return etaSeconds == null
+ ? rate
+ : `${rate} · ${formatDurationMs(etaSeconds * 1000)} left`
+}
+
/** `1.2.3` -> `v1.2.3`; a branch or PR build (`main`, `pr-12`, `dev`) is shown as is. */
export function formatVersion(version: string): string {
return /^\d/.test(version)
diff --git a/apps/ui/nuxt.config.ts b/apps/ui/nuxt.config.ts
index ba0d629..91feffa 100644
--- a/apps/ui/nuxt.config.ts
+++ b/apps/ui/nuxt.config.ts
@@ -3,7 +3,8 @@ import { execSync } from 'node:child_process'
import process from 'node:process'
// Releases are cut from git tags; package.json is never bumped. CI passes the
-// image version as APP_VERSION (`1.2.3`, `main`, `pr-12`); a local build falls
+// image's `org.opencontainers.image.version` label as APP_VERSION (`1.2.3`,
+// `main`, `pr-12`), so the UI shows what the image says it is. A local build falls
// back to the latest tag, then to `dev` (the Docker build context has no .git).
function appVersion(): string {
const fromEnv = process.env.APP_VERSION?.trim()
From bf652ca7f104c4885326013d83fc72ce389f51ae Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 22:21:34 +0100
Subject: [PATCH 07/18] feat: track uploads to peers and show them in the UI
---
apps/backend/drizzle/0014_uploads.sql | 19 +
apps/backend/drizzle/meta/0014_snapshot.json | 1047 +++++++++++++++++
apps/backend/drizzle/meta/_journal.json | 7 +
.../src/__tests__/upload-tracking.test.ts | 165 +++
.../src/__tests__/uploads-repository.test.ts | 69 ++
apps/backend/src/app.ts | 6 +-
apps/backend/src/database/schema.ts | 38 +
apps/backend/src/index.ts | 10 +
apps/backend/src/lib/openapi.ts | 1 +
apps/backend/src/management-app.ts | 4 +-
.../src/modules/peer/peer.controller.ts | 2 +
apps/backend/src/modules/peer/peer.router.ts | 27 +-
.../src/modules/status/status.controller.ts | 56 +-
.../src/modules/status/status.router.ts | 28 +
.../src/modules/uploads/counting-stream.ts | 76 ++
.../src/modules/uploads/uploads.repository.ts | 112 ++
.../src/modules/uploads/uploads.service.ts | 76 ++
apps/ui/app/layouts/default.vue | 3 +-
apps/ui/app/pages/index.vue | 52 +
apps/ui/app/pages/uploads.vue | 335 ++++++
apps/ui/app/types/management.ts | 30 +
website/guide/management-ui.md | 25 +-
22 files changed, 2177 insertions(+), 11 deletions(-)
create mode 100644 apps/backend/drizzle/0014_uploads.sql
create mode 100644 apps/backend/drizzle/meta/0014_snapshot.json
create mode 100644 apps/backend/src/__tests__/upload-tracking.test.ts
create mode 100644 apps/backend/src/__tests__/uploads-repository.test.ts
create mode 100644 apps/backend/src/modules/uploads/counting-stream.ts
create mode 100644 apps/backend/src/modules/uploads/uploads.repository.ts
create mode 100644 apps/backend/src/modules/uploads/uploads.service.ts
create mode 100644 apps/ui/app/pages/uploads.vue
diff --git a/apps/backend/drizzle/0014_uploads.sql b/apps/backend/drizzle/0014_uploads.sql
new file mode 100644
index 0000000..8a717ef
--- /dev/null
+++ b/apps/backend/drizzle/0014_uploads.sql
@@ -0,0 +1,19 @@
+CREATE TABLE `uploads` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `peer_name` text NOT NULL,
+ `item_id` text NOT NULL,
+ `filename` text NOT NULL,
+ `total_bytes` integer NOT NULL,
+ `range_start` integer DEFAULT 0 NOT NULL,
+ `requested_bytes` integer NOT NULL,
+ `sent_bytes` integer DEFAULT 0 NOT NULL,
+ `status` text NOT NULL,
+ `error` text,
+ `started_at` text NOT NULL,
+ `updated_at` text NOT NULL,
+ `finished_at` text,
+ CONSTRAINT "uploads_status_check" CHECK("uploads"."status" in ('uploading', 'completed', 'aborted', 'failed'))
+);
+--> statement-breakpoint
+CREATE INDEX `uploads_status_idx` ON `uploads` (`status`);--> statement-breakpoint
+CREATE INDEX `uploads_started_at_idx` ON `uploads` (`started_at`);
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/0014_snapshot.json b/apps/backend/drizzle/meta/0014_snapshot.json
new file mode 100644
index 0000000..f07d633
--- /dev/null
+++ b/apps/backend/drizzle/meta/0014_snapshot.json
@@ -0,0 +1,1047 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "330e5a4a-3d65-4303-b438-55329bcd8ca7",
+ "prevId": "7a0a25df-a2e9-4f9a-b477-42d28d9a01fc",
+ "tables": {
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "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
+ },
+ "attempts": {
+ "name": "attempts",
+ "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
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_details": {
+ "name": "error_details",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_operation": {
+ "name": "last_operation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'transfer'"
+ },
+ "operation_failed": {
+ "name": "operation_failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "qb_category": {
+ "name": "qb_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_source_server": {
+ "name": "qb_source_server",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_server_id": {
+ "name": "source_server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "import_mode": {
+ "name": "import_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "import_target": {
+ "name": "import_target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "manual_import_command_id": {
+ "name": "manual_import_command_id",
+ "type": "integer",
+ "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', 'import_queued', 'imported', 'failed')"
+ },
+ "downloads_expected_bytes_source_check": {
+ "name": "downloads_expected_bytes_source_check",
+ "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" in ('content_length', 'content_range', 'release_size')"
+ },
+ "downloads_last_operation_check": {
+ "name": "downloads_last_operation_check",
+ "value": "\"downloads\".\"last_operation\" in ('transfer', 'import')"
+ }
+ }
+ },
+ "managed_keys": {
+ "name": "managed_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "managed_keys_key_hash_unique": {
+ "name": "managed_keys_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_meta": {
+ "name": "own_catalog_meta",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_releases": {
+ "name": "own_catalog_releases",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "season": {
+ "name": "season",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "episode": {
+ "name": "episode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "own_catalog_releases_source_id_idx": {
+ "name": "own_catalog_releases_source_id_idx",
+ "columns": [
+ "source_id"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_title_key_idx": {
+ "name": "own_catalog_releases_title_key_idx",
+ "columns": [
+ "title_key"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_tmdb_id_idx": {
+ "name": "own_catalog_releases_tmdb_id_idx",
+ "columns": [
+ "tmdb_id"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_imdb_id_idx": {
+ "name": "own_catalog_releases_imdb_id_idx",
+ "columns": [
+ "imdb_id"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_tvdb_id_idx": {
+ "name": "own_catalog_releases_tvdb_id_idx",
+ "columns": [
+ "tvdb_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "own_catalog_releases_media_type_check": {
+ "name": "own_catalog_releases_media_type_check",
+ "value": "\"own_catalog_releases\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "own_catalog_source_reads": {
+ "name": "own_catalog_source_reads",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_source_titles": {
+ "name": "own_catalog_source_titles",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "own_catalog_source_titles_source_id_title_key_pk": {
+ "columns": [
+ "source_id",
+ "title_key"
+ ],
+ "name": "own_catalog_source_titles_source_id_title_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_titles": {
+ "name": "own_catalog_titles",
+ "columns": {
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_count": {
+ "name": "release_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total_size": {
+ "name": "total_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolutions": {
+ "name": "resolutions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "poster_url": {
+ "name": "poster_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "backdrop_url": {
+ "name": "backdrop_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "own_catalog_titles_revision_idx": {
+ "name": "own_catalog_titles_revision_idx",
+ "columns": [
+ "revision"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "own_catalog_titles_media_type_check": {
+ "name": "own_catalog_titles_media_type_check",
+ "value": "\"own_catalog_titles\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "peer_catalog_sync": {
+ "name": "peer_catalog_sync",
+ "columns": {
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "peer_catalog_titles": {
+ "name": "peer_catalog_titles",
+ "columns": {
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_count": {
+ "name": "release_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total_size": {
+ "name": "total_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolutions": {
+ "name": "resolutions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "poster_url": {
+ "name": "poster_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "backdrop_url": {
+ "name": "backdrop_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "peer_catalog_titles_peer_id_title_key_pk": {
+ "columns": [
+ "peer_id",
+ "title_key"
+ ],
+ "name": "peer_catalog_titles_peer_id_title_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "peer_catalog_titles_media_type_check": {
+ "name": "peer_catalog_titles_media_type_check",
+ "value": "\"peer_catalog_titles\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "tmdb_metadata": {
+ "name": "tmdb_metadata",
+ "columns": {
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "tmdb_metadata_media_type_tmdb_id_pk": {
+ "columns": [
+ "media_type",
+ "tmdb_id"
+ ],
+ "name": "tmdb_metadata_media_type_tmdb_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "tmdb_metadata_media_type_check": {
+ "name": "tmdb_metadata_media_type_check",
+ "value": "\"tmdb_metadata\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "uploads": {
+ "name": "uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "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
+ },
+ "total_bytes": {
+ "name": "total_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "range_start": {
+ "name": "range_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "requested_bytes": {
+ "name": "requested_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sent_bytes": {
+ "name": "sent_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uploads_status_idx": {
+ "name": "uploads_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "uploads_started_at_idx": {
+ "name": "uploads_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "uploads_status_check": {
+ "name": "uploads_status_check",
+ "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'aborted', 'failed')"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json
index 1739174..a4ef9a8 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -99,6 +99,13 @@
"when": 1793642343855,
"tag": "0013_milky_zzzax",
"breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "6",
+ "when": 1794172380544,
+ "tag": "0014_uploads",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/backend/src/__tests__/upload-tracking.test.ts b/apps/backend/src/__tests__/upload-tracking.test.ts
new file mode 100644
index 0000000..5eb9292
--- /dev/null
+++ b/apps/backend/src/__tests__/upload-tracking.test.ts
@@ -0,0 +1,165 @@
+import type { UploadOutcome } from '../modules/uploads/counting-stream'
+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 { Hono } from 'hono'
+import { openMemoryDatabase } from '../database/connection'
+import { PeerController } from '../modules/peer/peer.controller'
+import { getPeerRouter } from '../modules/peer/peer.router'
+import { countBytes } from '../modules/uploads/counting-stream'
+import { UploadsRepository } from '../modules/uploads/uploads.repository'
+import { UploadsService } from '../modules/uploads/uploads.service'
+
+function recorder() {
+ const progress: number[] = []
+ const ends: { outcome: UploadOutcome, sentBytes: number, error: string | null }[] = []
+ return {
+ progress,
+ ends,
+ hooks: {
+ onProgress: (sentBytes: number) => progress.push(sentBytes),
+ onEnd: (outcome: UploadOutcome, sentBytes: number, error: string | null) => ends.push({ outcome, sentBytes, error }),
+ },
+ }
+}
+
+// A blob streamed in several chunks, so partial reads are possible.
+function chunkedBlob() {
+ return new Blob([
+ new Uint8Array(3),
+ new Uint8Array(4),
+ new Uint8Array(5),
+ ])
+}
+
+describe('countBytes', () => {
+ test('passes the bytes through and ends as completed', async () => {
+ const { progress, ends, hooks } = recorder()
+ const body = await new Response(countBytes(new Blob([
+ new Uint8Array([1, 2, 3, 4]),
+ ]), hooks)).arrayBuffer()
+
+ expect([
+ ...new Uint8Array(body),
+ ]).toEqual([1, 2, 3, 4])
+ expect(progress.at(-1)).toBe(4)
+ expect(ends).toEqual([
+ { outcome: 'completed', sentBytes: 4, error: null },
+ ])
+ })
+
+ test('ends as aborted when the consumer cancels', async () => {
+ const { ends, hooks } = recorder()
+ const reader = countBytes(chunkedBlob(), hooks).getReader()
+ const first = await reader.read()
+ await reader.cancel()
+
+ expect(ends).toEqual([
+ { outcome: 'aborted', sentBytes: first.value!.byteLength, error: 'peer disconnected' },
+ ])
+ })
+
+ test('ends as aborted once when the request signal aborts', async () => {
+ const { ends, hooks } = recorder()
+ const controller = new AbortController()
+ const stream = countBytes(chunkedBlob(), hooks, controller.signal)
+ controller.abort()
+ await stream.cancel().catch(() => {})
+
+ expect(ends).toEqual([
+ { outcome: 'aborted', sentBytes: 0, error: 'peer disconnected' },
+ ])
+ })
+
+ test('ends as failed when the file cannot be read', async () => {
+ const { ends, hooks } = recorder()
+ const missing = Bun.file(join(tmpdir(), 'jack-missing-upload-file'))
+ await expect(new Response(countBytes(missing, hooks)).arrayBuffer()).rejects.toThrow()
+
+ expect(ends).toHaveLength(1)
+ expect(ends[0]!.outcome).toBe('failed')
+ })
+})
+
+describe('UploadsService.track', () => {
+ test('writes the row on finish and feeds the live tracker while running', async () => {
+ const repository = new UploadsRepository(openMemoryDatabase())
+ const service = new UploadsService(repository)
+ const stream = service.track({
+ peerName: 'alice',
+ itemId: 'radarr1:movie:1',
+ filename: 'Movie.mkv',
+ totalBytes: 12,
+ rangeStart: 0,
+ requestedBytes: 12,
+ }, chunkedBlob())
+
+ const [running] = repository.list()
+ expect(running?.status).toBe('uploading')
+ expect(service.live.get(running!.id)?.downloadedBytes).toBe(0)
+
+ await new Response(stream).arrayBuffer()
+
+ expect(repository.get(running!.id)).toMatchObject({ status: 'completed', sentBytes: 12 })
+ expect(service.live.get(running!.id)).toBeNull()
+ })
+})
+
+describe('GET /peer/items/:id/file upload tracking', () => {
+ let tempDir: string
+ let filePath: string
+
+ beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), 'jack-upload-'))
+ filePath = join(tempDir, 'Movie.mkv')
+ await Bun.write(filePath, new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]))
+ })
+
+ afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true })
+ })
+
+ function setup() {
+ const source = {
+ id: 'remote1',
+ name: 'My Radarr',
+ type: 'radarr',
+ canSource: true,
+ getFilePath: async () => filePath,
+ }
+ const repository = new UploadsRepository(openMemoryDatabase())
+ const app = new Hono()
+ app.route('/peer', getPeerRouter(new PeerController(() => [source as any]), new UploadsService(repository)))
+ return { app, repository }
+ }
+
+ test('records a range request with its start and the bytes sent', async () => {
+ const { app, repository } = setup()
+ const res = await app.request('/peer/items/remote1:movie:1/file', {
+ headers: { Range: 'bytes=6-' },
+ })
+ expect([
+ ...new Uint8Array(await res.arrayBuffer()),
+ ]).toEqual([6, 7, 8, 9])
+
+ expect(repository.list()).toEqual([
+ expect.objectContaining({
+ peerName: 'master',
+ itemId: 'remote1:movie:1',
+ filename: 'Movie.mkv',
+ totalBytes: 10,
+ rangeStart: 6,
+ requestedBytes: 4,
+ sentBytes: 4,
+ status: 'completed',
+ }),
+ ])
+ })
+
+ test('does not record a HEAD request', async () => {
+ const { app, repository } = setup()
+ await app.request('/peer/items/remote1:movie:1/file', { method: 'HEAD' })
+ expect(repository.list()).toEqual([])
+ })
+})
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
new file mode 100644
index 0000000..43f873a
--- /dev/null
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from 'bun:test'
+import { openMemoryDatabase } from '../database/connection'
+import { UploadsRepository } from '../modules/uploads/uploads.repository'
+
+function makeRepo() {
+ return new UploadsRepository(openMemoryDatabase())
+}
+
+const input = {
+ peerName: 'alice',
+ itemId: 'radarr1:movie:1',
+ filename: 'Movie.mkv',
+ totalBytes: 100,
+ rangeStart: 40,
+ requestedBytes: 60,
+}
+
+describe('UploadsRepository', () => {
+ test('creates an uploading row with nothing sent', () => {
+ const row = makeRepo().create(input)
+ expect(row).toMatchObject({
+ ...input,
+ sentBytes: 0,
+ status: 'uploading',
+ error: null,
+ finishedAt: null,
+ })
+ })
+
+ test('records progress and the final outcome', () => {
+ const repo = makeRepo()
+ const { id } = repo.create(input)
+
+ repo.updateProgress(id, 20)
+ expect(repo.get(id)?.sentBytes).toBe(20)
+
+ repo.finish(id, 'aborted', 30, 'peer disconnected')
+ expect(repo.get(id)).toMatchObject({ status: 'aborted', sentBytes: 30, error: 'peer disconnected' })
+ expect(repo.get(id)?.finishedAt).not.toBeNull()
+ })
+
+ test('lists newest first', () => {
+ const repo = makeRepo()
+ const first = repo.create(input)
+ const second = repo.create(input)
+ expect(repo.list().map(r => r.id)).toEqual([second.id, first.id])
+ })
+
+ test('marks rows left uploading by a previous run as aborted', () => {
+ const repo = makeRepo()
+ const running = repo.create(input)
+ const done = repo.create(input)
+ repo.finish(done.id, 'completed', 60)
+
+ expect(repo.reconcileInterrupted()).toBe(1)
+ expect(repo.get(running.id)).toMatchObject({ status: 'aborted', error: 'interrupted by a jack restart' })
+ expect(repo.get(done.id)?.status).toBe('completed')
+ })
+
+ test('notifies subscribers on every change', () => {
+ const repo = makeRepo()
+ let calls = 0
+ repo.subscribe(() => calls++)
+ const { id } = repo.create(input)
+ repo.updateProgress(id, 1)
+ repo.finish(id, 'completed', 60)
+ expect(calls).toBe(3)
+ })
+})
diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts
index 7f21166..14bab53 100644
--- a/apps/backend/src/app.ts
+++ b/apps/backend/src/app.ts
@@ -6,6 +6,7 @@ import type { OwnCatalogRepository } from './modules/catalog/own-catalog.reposit
import type { DownloadsRepository } from './modules/downloads/downloads.repository'
import type { DownloadsService } from './modules/downloads/downloads.service'
import type { ManagedKeysRepository } from './modules/managed-keys/managed-keys.repository'
+import type { UploadsService } from './modules/uploads/uploads.service'
import { httpInstrumentationMiddleware } from '@hono/otel'
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
@@ -35,6 +36,9 @@ interface AppServices {
downloadsService?: DownloadsService
// Serves `/peer/catalog`; without it the catalog comes back empty.
ownCatalogRepository?: OwnCatalogRepository
+ // Records files peers pull from `/peer/items/:id/file`; without it they are
+ // served untracked.
+ uploadsService?: UploadsService
}
// This is the external surface, gated per audience rather than by one global key:
@@ -134,7 +138,7 @@ export function getApp(envs: Envs, config: AppConfig, connManager: { servers: Co
// Peer API — other Jacks talk to us to search and download. Serves empty results
// when there's no local source to read from.
app.use('/peer/*', peerAuth)
- app.route('/peer', getPeerRouter(peerController))
+ app.route('/peer', getPeerRouter(peerController, services.uploadsService))
// Torznab API — the operator's Radarr/Sonarr search through us and grab via the
// download router. Returns empty results when there are no peers to fan out to.
diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts
index 9f2aabe..98f396b 100644
--- a/apps/backend/src/database/schema.ts
+++ b/apps/backend/src/database/schema.ts
@@ -81,6 +81,44 @@ export const apiKeys = sqliteTable('api_keys', {
updatedAt: text('updated_at').notNull(),
})
+// Files peers pull from this instance through `/peer/items/:id/file`. One row
+// per HTTP request: a resumed transfer sends a Range header and gets its own
+// row, starting at `rangeStart`.
+export const UPLOAD_STATUSES = [
+ 'uploading',
+ 'completed',
+ 'aborted',
+ 'failed',
+] as const
+export type UploadStatus = typeof UPLOAD_STATUSES[number]
+
+export const uploads = sqliteTable('uploads', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ // Name of the api key the peer authenticated with ('master' for the master key).
+ peerName: text('peer_name').notNull(),
+ itemId: text('item_id').notNull(),
+ filename: text('filename').notNull(),
+ // Size of the whole file on disk.
+ totalBytes: integer('total_bytes').notNull(),
+ // First byte this request serves (0 without a Range header).
+ rangeStart: integer('range_start').notNull().default(0),
+ // Bytes this request was asked to send (the response Content-Length).
+ requestedBytes: integer('requested_bytes').notNull(),
+ sentBytes: integer('sent_bytes').notNull().default(0),
+ status: text('status').$type().notNull(),
+ error: text('error'),
+ startedAt: text('started_at').notNull(),
+ updatedAt: text('updated_at').notNull(),
+ finishedAt: text('finished_at'),
+}, t => [
+ check('uploads_status_check', sql`${t.status} in ('uploading', 'completed', 'aborted', 'failed')`),
+ index('uploads_status_idx').on(t.status),
+ index('uploads_started_at_idx').on(t.startedAt),
+])
+
+export type UploadRow = typeof uploads.$inferSelect
+export type NewUploadRow = typeof uploads.$inferInsert
+
export type ApiKeyRow = typeof apiKeys.$inferSelect
export type NewApiKeyRow = typeof apiKeys.$inferInsert
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index 5867120..3259c91 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -24,6 +24,8 @@ import { ImportWatcher } from './modules/downloads/import-watcher'
import { ManagedKeysRepository } from './modules/managed-keys/managed-keys.repository'
import { ManagedApiKeys } from './modules/managed-keys/managed-keys.service'
import { qbCategoryForServer } from './modules/qbittorrent/qbittorrent.mapper'
+import { UploadsRepository } from './modules/uploads/uploads.repository'
+import { UploadsService } from './modules/uploads/uploads.service'
function logRegistrationFailure(what: string, destName: string | undefined, err: unknown) {
if (err instanceof FetchError) {
@@ -51,6 +53,12 @@ const apiKeysRepository = new ApiKeysRepository(database.db)
const managedKeysRepository = new ManagedKeysRepository(database.db)
const catalogCacheRepository = new CatalogCacheRepository(database.db)
const tmdbMetadataRepository = new TmdbMetadataRepository(database.db)
+const uploadsService = new UploadsService(new UploadsRepository(database.db))
+
+// Peers connected to the previous run lost those transfers with it.
+const interruptedUploads = uploadsService.repository.reconcileInterrupted()
+if (interruptedUploads > 0)
+ logger.warn({ uploads: interruptedUploads }, 'Marked uploads interrupted by the previous jack run as aborted')
// Seed the management service from the shared raw object returned by getAppConfig
// so the service's persisted state can never diverge from the loaded runtime config.
@@ -87,6 +95,7 @@ const app = getApp(envs, config, connectorManager, {
apiKeysRepository,
managedKeysRepository,
ownCatalogRepository,
+ uploadsService,
})
const server = Bun.serve({
fetch: app.fetch,
@@ -156,6 +165,7 @@ function startManagementServer() {
downloadsRepository,
downloadsService,
importWatcher,
+ uploadsService,
apiKeysRepository,
catalogCacheRepository,
tmdbMetadataRepository,
diff --git a/apps/backend/src/lib/openapi.ts b/apps/backend/src/lib/openapi.ts
index 5e8824a..be7d3ea 100644
--- a/apps/backend/src/lib/openapi.ts
+++ b/apps/backend/src/lib/openapi.ts
@@ -69,6 +69,7 @@ export const managementDocumentation: GenerateSpecOptions['documentation'] = {
{ name: 'System', description: 'Health probe and the connector overview.' },
{ name: 'Config', description: 'Read and mutate the persisted jack/servers/peers configuration.' },
{ name: 'Downloads', description: 'List and stream download records, and manage individual downloads by their numeric id.' },
+ { name: 'Uploads', description: 'List and stream the files peers pull from this instance.' },
{ name: 'Logs', description: 'Backfill and live-tail the process\'s own logs.' },
{ name: 'Catalog', description: 'Aggregated peer catalog and TMDB-backed metadata for the UI.' },
{ name: 'API keys', description: 'Issue, list, and revoke the peer API keys this instance hands out.' },
diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts
index 981d28b..12411e4 100644
--- a/apps/backend/src/management-app.ts
+++ b/apps/backend/src/management-app.ts
@@ -7,6 +7,7 @@ import type { ConfigService } from './modules/config/config.service'
import type { DownloadsRepository } from './modules/downloads/downloads.repository'
import type { DownloadsService } from './modules/downloads/downloads.service'
import type { ImportWatcher } from './modules/downloads/import-watcher'
+import type { UploadsService } from './modules/uploads/uploads.service'
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
import { secureHeaders } from 'hono/secure-headers'
@@ -40,6 +41,7 @@ export function getManagementApp(params: {
downloadsRepository?: DownloadsRepository
downloadsService?: DownloadsService
importWatcher?: ImportWatcher
+ uploadsService?: UploadsService
apiKeysRepository?: ApiKeysRepository
catalogCacheRepository?: CatalogCacheRepository
tmdbMetadataRepository?: TmdbMetadataRepository
@@ -84,7 +86,7 @@ export function getManagementApp(params: {
const configController = new ConfigController(params.connectors, params.configService)
app.route('/config', getConfigRouter(configController))
- const statusController = new StatusController(params.connectors, params.downloadsRepository)
+ const statusController = new StatusController(params.connectors, params.downloadsRepository, params.uploadsService)
app.route('/', getStatusRouter(statusController))
app.route('/downloads', getDownloadsManagementRouter(new DownloadsManagementController(
diff --git a/apps/backend/src/modules/peer/peer.controller.ts b/apps/backend/src/modules/peer/peer.controller.ts
index 267d34e..caa3d94 100644
--- a/apps/backend/src/modules/peer/peer.controller.ts
+++ b/apps/backend/src/modules/peer/peer.controller.ts
@@ -40,6 +40,8 @@ export function parseRangeHeader(value: string | undefined | null): { start?: nu
// backpressure (sendfile): if the consumer stalls or aborts mid-download, we stop
// reading from disk instead of buffering the whole file into RAM. Pumping `.stream()`
// ourselves does NOT backpressure and lets one stalled peer OOM the process.
+// Upload tracking (uploads/counting-stream.ts) wraps this body in a pull-only
+// stream that reads a chunk only when the socket asks for one, which keeps it.
export type StreamFileResult
= | { type: 'full', body: Blob, size: number, filename: string }
| { type: 'partial', body: Blob, size: number, totalSize: number, start: number, end: number, filename: string }
diff --git a/apps/backend/src/modules/peer/peer.router.ts b/apps/backend/src/modules/peer/peer.router.ts
index af780b8..fa3ae2d 100644
--- a/apps/backend/src/modules/peer/peer.router.ts
+++ b/apps/backend/src/modules/peer/peer.router.ts
@@ -1,3 +1,5 @@
+import type { AuthVariables } from '../../middleware/require-auth'
+import type { UploadsService } from '../uploads/uploads.service'
import type { PeerController } from './peer.controller'
import { Hono } from 'hono'
import { describeRoute, resolver, validator as zValidator } from 'hono-openapi'
@@ -12,8 +14,8 @@ function safeFilename(name: string): string {
return name.replace(UNSAFE_FILENAME_CHARS, '')
}
-export function getPeerRouter(controller: PeerController) {
- const app = new Hono()
+export function getPeerRouter(controller: PeerController, uploads?: UploadsService) {
+ const app = new Hono<{ Variables: AuthVariables }>()
app.get(
'/search',
@@ -132,8 +134,25 @@ export function getPeerRouter(controller: PeerController) {
})
}
+ // Count what the peer actually reads. A HEAD request sends no body, so it is
+ // not an upload. The master key sets no name.
+ const body = uploads && c.req.method !== 'HEAD'
+ ? uploads.track({
+ peerName: c.get('apiKeyName') ?? 'master',
+ itemId,
+ filename: result.filename,
+ totalBytes: result.type === 'partial'
+ ? result.totalSize
+ : result.size,
+ rangeStart: result.type === 'partial'
+ ? result.start
+ : 0,
+ requestedBytes: result.size,
+ }, result.body, c.req.raw.signal)
+ : result.body
+
if (result.type === 'partial') {
- return new Response(result.body, {
+ return new Response(body, {
status: 206,
headers: {
'Content-Type': 'application/octet-stream',
@@ -145,7 +164,7 @@ export function getPeerRouter(controller: PeerController) {
})
}
- return new Response(result.body, {
+ return new Response(body, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(result.size),
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index ab0310c..7940071 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -1,10 +1,12 @@
-import type { DownloadStatus } from '../../database/schema'
+import type { DownloadStatus, UploadStatus } from '../../database/schema'
import type { ArrServerConnector } from '../../lib/servers/arr/base'
import type { ServerConnector } from '../../lib/servers/base'
import type { PeerConnector } from '../../lib/servers/peer'
import type { DownloadRecord, DownloadsRepository } from '../downloads/downloads.repository'
import type { LiveProgressTracker } from '../downloads/live-progress'
-import { DOWNLOAD_STATUSES } from '../../database/schema'
+import type { UploadRecord } from '../uploads/uploads.repository'
+import type { UploadsService } from '../uploads/uploads.service'
+import { DOWNLOAD_STATUSES, UPLOAD_STATUSES } from '../../database/schema'
import { liveProgressFor } from '../downloads/live-progress'
// The management surface can expose richer detail than the public API: every
@@ -55,12 +57,33 @@ export function enrichDownload(d: DownloadRecord, live?: LiveProgressTracker) {
}
}
+export function enrichUpload(u: UploadRecord, live?: LiveProgressTracker) {
+ // The row is written every 10 s; a running upload has a fresher count in memory.
+ const current = u.status === 'uploading'
+ ? live?.get(u.id)
+ : null
+ const sentBytes = Math.max(u.sentBytes, current?.downloadedBytes ?? 0)
+ // Progress through the whole file, so a resumed request starts where the range does.
+ const progress = u.totalBytes > 0
+ ? Math.min(1, (u.rangeStart + sentBytes) / u.totalBytes)
+ : null
+ return {
+ ...u,
+ sentBytes,
+ progress,
+ speedBps: current?.speedBps ?? null,
+ }
+}
+
export class StatusController {
constructor(
private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[], subscribe?: (subscriber: () => void) => () => void },
private readonly downloads?: DownloadsRepository,
+ private readonly uploads?: UploadsService,
) {}
+ private enrichUpload = (u: UploadRecord) => enrichUpload(u, this.uploads?.live)
+
private get live(): LiveProgressTracker | undefined {
return this.downloads && liveProgressFor(this.downloads)
}
@@ -72,6 +95,7 @@ export class StatusController {
this.connectors.subscribe?.(subscriber),
this.downloads?.subscribe(subscriber),
this.live?.subscribe(subscriber),
+ this.uploads?.subscribe(subscriber),
].filter((unsubscribe): unsubscribe is () => void => Boolean(unsubscribe))
return () => {
for (const unsubscribe of unsubscribers)
@@ -79,6 +103,10 @@ export class StatusController {
}
}
+ subscribeUploads(subscriber: () => void): () => void {
+ return this.uploads?.subscribe(subscriber) ?? (() => {})
+ }
+
subscribeDownloads(subscriber: () => void): () => void {
const unsubscribers = [
this.downloads?.subscribe(subscriber),
@@ -144,6 +172,30 @@ export class StatusController {
importQueued: records.filter(r => r.status === 'import_queued').slice(0, ATTENTION_LIMIT).map(this.enrich),
failed: records.filter(r => r.status === 'failed').slice(0, ATTENTION_LIMIT).map(this.enrich),
},
+ uploads: this.getUploadsSummary(),
+ }
+ }
+
+ private getUploadsSummary() {
+ const records = this.uploads?.list() ?? []
+ const byStatus = Object.fromEntries(UPLOAD_STATUSES.map(s => [s, 0])) as Record
+ let bytesSent = 0
+ for (const record of records) {
+ byStatus[record.status]++
+ bytesSent += record.sentBytes
+ }
+ return {
+ total: records.length,
+ byStatus,
+ bytesSent,
+ active: records.filter(r => r.status === 'uploading').map(this.enrichUpload),
+ }
+ }
+
+ listUploads() {
+ const records = this.uploads?.list() ?? []
+ return {
+ uploads: records.map(this.enrichUpload),
}
}
diff --git a/apps/backend/src/modules/status/status.router.ts b/apps/backend/src/modules/status/status.router.ts
index 6c9e5d9..4284f21 100644
--- a/apps/backend/src/modules/status/status.router.ts
+++ b/apps/backend/src/modules/status/status.router.ts
@@ -62,6 +62,34 @@ export function getStatusRouter(controller: StatusController) {
},
},
}), c => streamSnapshots(c, () => controller.listDownloads(), subscriber => controller.subscribeDownloads(subscriber)))
+ app.get('/uploads', describeRoute({
+ tags: ['Uploads'],
+ summary: 'List uploads',
+ description: 'Files peers pulled or are pulling from this instance, newest first. One record per file request, so a resumed transfer adds a record that starts at its `rangeStart`.',
+ security: [
+ { 'X-Management-Key': [] },
+ ],
+ responses: {
+ 200: {
+ description: 'Upload records',
+ content: { 'application/json': {} },
+ },
+ },
+ }), c => c.json(controller.listUploads()))
+ app.get('/uploads/stream', describeRoute({
+ tags: ['Uploads'],
+ summary: 'Stream uploads',
+ description: 'Server-Sent Events stream with an initial upload snapshot and an update after every upload change, at most about once a second while files stream.',
+ security: [
+ { 'X-Management-Key': [] },
+ ],
+ responses: {
+ 200: {
+ description: 'Live upload snapshots',
+ content: { 'text/event-stream': {} },
+ },
+ },
+ }), c => streamSnapshots(c, () => controller.listUploads(), subscriber => controller.subscribeUploads(subscriber)))
return app
}
diff --git a/apps/backend/src/modules/uploads/counting-stream.ts b/apps/backend/src/modules/uploads/counting-stream.ts
new file mode 100644
index 0000000..ac9de33
--- /dev/null
+++ b/apps/backend/src/modules/uploads/counting-stream.ts
@@ -0,0 +1,76 @@
+export type UploadOutcome = 'completed' | 'aborted' | 'failed'
+
+export interface ByteCountingHooks {
+ /** Called after every chunk with the running total. */
+ onProgress: (sentBytes: number) => void
+ /** Called exactly once, when the body was fully read, cancelled, or failed. */
+ onEnd: (outcome: UploadOutcome, sentBytes: number, error: string | null) => void
+}
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error
+ ? err.message
+ : String(err)
+}
+
+/**
+ * Wraps a file body in a stream that counts the bytes handed to the consumer.
+ *
+ * The stream is pull-based with a zero high-water mark: a chunk is read from
+ * disk only when the consumer asks for one, so a stalled peer still holds the
+ * read back instead of buffering the file in memory. A cancel from the consumer
+ * or an abort on `signal` (the peer disconnected) ends it as `aborted`.
+ */
+export function countBytes(body: Blob, hooks: ByteCountingHooks, signal?: AbortSignal): ReadableStream {
+ // Opened on the first pull, so a file that vanished since the existence check
+ // fails the stream (and ends as `failed`) instead of throwing here.
+ const openReader = () => body.stream().getReader()
+ let reader: ReturnType | undefined
+ let sentBytes = 0
+ let ended = false
+
+ const end = (outcome: UploadOutcome, error: string | null = null) => {
+ if (ended)
+ return
+ ended = true
+ signal?.removeEventListener('abort', onAbort)
+ hooks.onEnd(outcome, sentBytes, error)
+ }
+
+ function onAbort() {
+ end('aborted', 'peer disconnected')
+ reader?.cancel().catch(() => {})
+ }
+
+ if (signal?.aborted)
+ onAbort()
+ else
+ signal?.addEventListener('abort', onAbort, { once: true })
+
+ return new ReadableStream({
+ async pull(controller) {
+ try {
+ reader ??= openReader()
+ const { done, value } = await reader.read()
+ if (done) {
+ controller.close()
+ end('completed')
+ return
+ }
+ sentBytes += value.byteLength
+ controller.enqueue(value)
+ hooks.onProgress(sentBytes)
+ }
+ catch (err) {
+ end('failed', errorMessage(err))
+ controller.error(err)
+ }
+ },
+ async cancel(reason) {
+ end('aborted', reason === undefined
+ ? 'peer disconnected'
+ : errorMessage(reason))
+ await reader?.cancel(reason).catch(() => {})
+ },
+ }, { highWaterMark: 0 })
+}
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
new file mode 100644
index 0000000..12412ef
--- /dev/null
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -0,0 +1,112 @@
+import type { AppDatabase } from '../../database/connection'
+import type { NewUploadRow, UploadRow, UploadStatus } from '../../database/schema'
+import { desc, eq } from 'drizzle-orm'
+import { uploads } from '../../database/schema'
+
+export type UploadRecord = UploadRow
+
+export interface CreateUploadInput {
+ peerName: string
+ itemId: string
+ filename: string
+ totalBytes: number
+ rangeStart: number
+ requestedBytes: number
+}
+
+function nowIso() {
+ return new Date().toISOString()
+}
+
+/**
+ * Files peers pull from this instance, one row per request. Rows are kept after
+ * they finish so the list doubles as a history.
+ */
+export class UploadsRepository {
+ private readonly subscribers = new Set<() => void>()
+
+ constructor(private readonly db: AppDatabase) {}
+
+ subscribe(subscriber: () => void): () => void {
+ this.subscribers.add(subscriber)
+ return () => this.subscribers.delete(subscriber)
+ }
+
+ private notifyChanged(): void {
+ for (const subscriber of this.subscribers) {
+ try {
+ subscriber()
+ }
+ catch {
+ // Observers must never make a committed database mutation look failed.
+ }
+ }
+ }
+
+ create(input: CreateUploadInput): UploadRecord {
+ const timestamp = nowIso()
+ const values: NewUploadRow = {
+ ...input,
+ sentBytes: 0,
+ status: 'uploading',
+ startedAt: timestamp,
+ updatedAt: timestamp,
+ }
+ const row = this.db.insert(uploads).values(values).returning().get()
+ this.notifyChanged()
+ return row
+ }
+
+ get(id: number): UploadRecord | null {
+ return this.db.select().from(uploads).where(eq(uploads.id, id)).get() ?? null
+ }
+
+ /** Newest first; the id breaks ties between rows started in the same millisecond. */
+ list(): UploadRecord[] {
+ return this.db.select().from(uploads).orderBy(desc(uploads.startedAt), desc(uploads.id)).all()
+ }
+
+ updateProgress(id: number, sentBytes: number): void {
+ this.db.update(uploads)
+ .set({
+ sentBytes,
+ updatedAt: nowIso(),
+ })
+ .where(eq(uploads.id, id))
+ .run()
+ this.notifyChanged()
+ }
+
+ finish(id: number, status: Exclude, sentBytes: number, error: string | null = null): void {
+ const timestamp = nowIso()
+ this.db.update(uploads)
+ .set({
+ status,
+ sentBytes,
+ error,
+ updatedAt: timestamp,
+ finishedAt: timestamp,
+ })
+ .where(eq(uploads.id, id))
+ .run()
+ this.notifyChanged()
+ }
+
+ /** Rows still `uploading` from a previous run lost their connection with it. */
+ reconcileInterrupted(): number {
+ const timestamp = nowIso()
+ const rows = this.db.update(uploads)
+ .set({
+ status: 'aborted',
+ error: 'interrupted by a jack restart',
+ updatedAt: timestamp,
+ finishedAt: timestamp,
+ })
+ .where(eq(uploads.status, 'uploading'))
+ .returning({ id: uploads.id })
+ .all()
+ if (rows.length > 0)
+ this.notifyChanged()
+ return rows.length
+ }
+}
diff --git a/apps/backend/src/modules/uploads/uploads.service.ts b/apps/backend/src/modules/uploads/uploads.service.ts
new file mode 100644
index 0000000..99ee2b5
--- /dev/null
+++ b/apps/backend/src/modules/uploads/uploads.service.ts
@@ -0,0 +1,76 @@
+import type { CreateUploadInput, UploadRecord, UploadsRepository } from './uploads.repository'
+import { logger } from '../../logger'
+import { LiveProgressTracker } from '../downloads/live-progress'
+import { countBytes } from './counting-stream'
+
+// The row is written this often while a file streams (and once at the end).
+const PERSIST_INTERVAL_MS = 10_000
+// The live tracker gets a sample this often, enough for a smooth speed reading.
+const LIVE_INTERVAL_MS = 1000
+
+/**
+ * Records files peers pull from `/peer/items/:id/file`. Each request gets an
+ * `uploads` row; the bytes are counted as the peer reads them, persisted every
+ * 10 s, and fed to an in-memory tracker about once a second for live speed.
+ */
+export class UploadsService {
+ readonly live: LiveProgressTracker
+
+ constructor(
+ readonly repository: UploadsRepository,
+ private readonly now: () => number = Date.now,
+ ) {
+ this.live = new LiveProgressTracker(now)
+ }
+
+ /** Starts a row for this request and returns the body to send instead of `body`. */
+ track(input: CreateUploadInput, body: Blob, signal?: AbortSignal): ReadableStream {
+ const upload = this.repository.create(input)
+ let lastPersist = this.now()
+ let lastSample = lastPersist
+ this.live.record(upload.id, 0)
+
+ return countBytes(body, {
+ onProgress: (sentBytes) => {
+ const t = this.now()
+ if (t - lastSample >= LIVE_INTERVAL_MS) {
+ lastSample = t
+ this.live.record(upload.id, sentBytes)
+ }
+ if (t - lastPersist >= PERSIST_INTERVAL_MS) {
+ lastPersist = t
+ this.write(upload.id, () => this.repository.updateProgress(upload.id, sentBytes))
+ }
+ },
+ onEnd: (outcome, sentBytes, error) => {
+ this.live.clear(upload.id)
+ this.write(upload.id, () => this.repository.finish(upload.id, outcome, sentBytes, error))
+ },
+ }, signal)
+ }
+
+ list(): UploadRecord[] {
+ return this.repository.list()
+ }
+
+ subscribe(subscriber: () => void): () => void {
+ const unsubscribers = [
+ this.repository.subscribe(subscriber),
+ this.live.subscribe(subscriber),
+ ]
+ return () => {
+ for (const unsubscribe of unsubscribers)
+ unsubscribe()
+ }
+ }
+
+ // Bookkeeping must never break the transfer it describes.
+ private write(id: number, fn: () => void): void {
+ try {
+ fn()
+ }
+ catch (err) {
+ logger.warn({ uploadId: id, err }, 'Failed to record upload progress')
+ }
+ }
+}
diff --git a/apps/ui/app/layouts/default.vue b/apps/ui/app/layouts/default.vue
index f9565e5..adebe65 100644
--- a/apps/ui/app/layouts/default.vue
+++ b/apps/ui/app/layouts/default.vue
@@ -5,7 +5,7 @@ const { state, logout } = useAuth()
const version = useRuntimeConfig().public.appVersion
// Peers and *arr servers are managed as sections under Settings, so the nav is
-// just the three live views plus configuration.
+// just the live views plus configuration.
const items: NavigationMenuItem[] = [
{
label: 'Dashboard',
@@ -14,6 +14,7 @@ const items: NavigationMenuItem[] = [
exact: true,
},
{ label: 'Downloads', icon: 'i-ph-download-simple', to: '/downloads' },
+ { label: 'Uploads', icon: 'i-ph-upload-simple', to: '/uploads' },
{ label: 'Peer Catalog', icon: 'i-ph-film-reel', to: '/catalog' },
{ label: 'Logs', icon: 'i-ph-scroll', to: '/logs' },
{ label: 'Settings', icon: 'i-ph-gear-six', to: '/settings' },
diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue
index 7e9e453..ba5e9bd 100644
--- a/apps/ui/app/pages/index.vue
+++ b/apps/ui/app/pages/index.vue
@@ -119,6 +119,58 @@ function serverRole(server: { source: boolean, destination: boolean }) {
+
+
+
+
+ Uploads
+
+
+
+
+
+
+
+ {{ overview.uploads.byStatus.uploading ?? 0 }}
+ Uploading
+
+
+ {{ overview.uploads.byStatus.completed ?? 0 }}
+ Completed
+
+
+ {{ formatBytes(overview.uploads.bytesSent) }}
+ Sent
+
+
+
+
+ No peer is pulling from you right now.
+
+
+
+
+
+ {{ u.filename }}
+
+
+ to {{ u.peerName }}
+
+
+
+
+
+ {{ formatBytes(u.rangeStart + u.sentBytes) }} / {{ formatBytes(u.totalBytes) }}
+
+
+
+ {{ formatTransferRate(u.speedBps, null) }}
+
+
+
+
+
+
diff --git a/apps/ui/app/pages/uploads.vue b/apps/ui/app/pages/uploads.vue
new file mode 100644
index 0000000..7332a8b
--- /dev/null
+++ b/apps/ui/app/pages/uploads.vue
@@ -0,0 +1,335 @@
+
+
+
+
+
+
+
+
+ {{ connected ? 'Live' : 'Reconnecting…' }}
+
+
+
+
+
+
+ { filter = chip.key }"
+ >
+ {{ chip.label }}
+
+ {{ counts[chip.key] }}
+
+
+
+
+
+
+
+
+
+
+ Peer
+
+
+
+
+ {{ (item as { count?: number }).count }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading…
+
+
+
+
+
+
+ No uploads yet. Files show up here once a peer starts pulling from your library.
+
+
+
+
+
+
+
+
+
+
+ {{ row.original.filename }}
+
+
+ resumed at {{ formatBytes(row.original.rangeStart) }}
+
+
+ {{ row.original.error }}
+
+
+
+
+ {{ row.original.peerName }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatTransferRate(row.original.speedBps, etaSeconds(row.original)) }}
+
+
+
+
+
+ {{ formatBytes(row.original.sentBytes) }}
+
+
+
+ {{ formatBytes(row.original.totalBytes) }}
+
+
+
+
+ {{ formatAgo(row.original.startedAt) }} ago
+
+
+
+
+
+ {{ formatAgo(row.original.finishedAt) }} ago
+
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ u.filename }}
+
+
+ {{ u.error }}
+
+
+
+
+
+
+ {{ formatTransferRate(u.speedBps, etaSeconds(u)) }}
+
+
+ {{ u.peerName }}
+ {{ formatBytes(u.sentBytes) }} of {{ formatBytes(u.totalBytes) }} · {{ formatAgo(u.startedAt) }} ago
+
+
+
+
+
+
+
+
+
+ Showing {{ showingFrom }}–{{ showingTo }} of {{ total }}
+
+
+
+
+
+
+
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts
index 2da8658..2ae1852 100644
--- a/apps/ui/app/types/management.ts
+++ b/apps/ui/app/types/management.ts
@@ -86,6 +86,30 @@ export interface DownloadItem {
expectedBytesMismatch: boolean
}
+// A file a peer pulls from this instance. One per file request: a resumed
+// transfer is a new upload starting at `rangeStart`.
+export interface UploadItem {
+ id: number
+ // The API key the peer used; 'master' for the master key.
+ peerName: string
+ itemId: string
+ filename: string
+ // The whole file's size on disk.
+ totalBytes: number
+ rangeStart: number
+ requestedBytes: number
+ sentBytes: number
+ // Through the whole file, counting the range start.
+ progress: number | null
+ // Live, while uploading. Null otherwise.
+ speedBps: number | null
+ status: 'uploading' | 'completed' | 'aborted' | 'failed'
+ error: string | null
+ startedAt: string
+ updatedAt: string
+ finishedAt: string | null
+}
+
export interface TmdbMetadata {
tmdbId: number
title: string
@@ -183,6 +207,12 @@ export interface Overview {
importQueued: DownloadItem[]
failed: DownloadItem[]
}
+ uploads: {
+ total: number
+ byStatus: Record
+ bytesSent: number
+ active: UploadItem[]
+ }
}
// Secret refs mirror the backend's RawConfigSecret union.
diff --git a/website/guide/management-ui.md b/website/guide/management-ui.md
index 6c494be..f86e59f 100644
--- a/website/guide/management-ui.md
+++ b/website/guide/management-ui.md
@@ -18,6 +18,8 @@ operate jack day to day:
grabs, updating live as they progress. A failed download shows its
[error code](/guide/troubleshooting#download-error-codes), linked to its
entry in the troubleshooting guide, and the list can be filtered by code.
+- ***Uploads***: the files peers pull from you, live and past. See
+ [Uploads](#uploads).
- ***Peers*** and ***Servers***: add, edit, and remove your friends and your
Radarr/Sonarr connectors.
- ***Settings -> Downloads***: the whole
@@ -32,6 +34,24 @@ operate jack day to day:
Don't want it? Delete the `jack-ui` service and the backend's
`JACK_MANAGEMENT_KEY` line to run jack headless.
+## Uploads
+
+When a peer downloads a file from your library, jack records it as an upload.
+The ***Uploads*** page lists them newest first, read-only, with:
+
+- **Peer**: the name of the API key the peer used, or `master` for the master
+ key.
+- **File**: the file name, with its size on disk.
+- **Sent**: bytes the peer has read so far, with live speed while it runs.
+- **Status**: `uploading`, `completed`, `aborted` when the peer disconnected
+ or jack restarted mid-transfer, or `failed` when jack could not read the
+ file.
+
+Each file request is its own row. A peer that resumes a transfer sends a
+`Range` request, so the resume shows as a new row whose progress starts where
+the range does. The ***Overview*** page shows the uploads running now next to
+active downloads.
+
## Settings -> Downloads
The whole [`downloads`](/reference/configuration#downloads) block is editable
@@ -104,7 +124,7 @@ for the full UI configuration reference.
## Live updates
-The UI doesn't poll. Overview, Downloads, and Settings each hold a
+The UI doesn't poll. Overview, Downloads, Uploads, and Settings each hold a
[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
stream open through the BFF, and jack pushes a fresh snapshot the moment
something changes: a transfer's progress, a connector's state, or a peer or
@@ -115,8 +135,9 @@ The streams are plain `GET` endpoints on the management API:
| Stream | What it pushes |
| --- | --- |
-| `/overview/stream` | Connector overview + download state |
+| `/overview/stream` | Connector overview + download and upload state |
| `/downloads/stream` | Every persisted download change |
+| `/uploads/stream` | Every upload change, about once a second while files stream |
| `/config/stream` | Peer and server config changes |
Each sends its current snapshot immediately on connect, then a full snapshot per
From 1bb8b73cc9cab8799db9b7135c4aabaeecab9efe Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 23:45:08 +0100
Subject: [PATCH 08/18] perf: serve uploads zero-copy and take bytes and
duration from a peer receipt
---
apps/backend/drizzle/0014_uploads.sql | 3 +-
apps/backend/drizzle/meta/0014_snapshot.json | 11 +-
apps/backend/drizzle/meta/_journal.json | 2 +-
.../src/__tests__/peer-download.test.ts | 39 +++++
.../src/__tests__/upload-tracking.test.ts | 147 +++++++-----------
.../src/__tests__/uploads-repository.test.ts | 61 +++++---
apps/backend/src/database/schema.ts | 12 +-
apps/backend/src/index.ts | 2 +
apps/backend/src/lib/servers/peer.ts | 41 ++++-
.../src/modules/peer/peer.controller.ts | 6 +-
apps/backend/src/modules/peer/peer.router.ts | 48 +++++-
.../src/modules/status/status.controller.ts | 25 ++-
.../src/modules/uploads/counting-stream.ts | 76 ---------
.../src/modules/uploads/uploads.repository.ts | 70 ++++++---
.../src/modules/uploads/uploads.service.ts | 82 +++-------
apps/ui/app/pages/index.vue | 11 +-
apps/ui/app/pages/uploads.vue | 41 ++---
apps/ui/app/types/management.ts | 11 +-
website/guide/management-ui.md | 20 ++-
19 files changed, 377 insertions(+), 331 deletions(-)
delete mode 100644 apps/backend/src/modules/uploads/counting-stream.ts
diff --git a/apps/backend/drizzle/0014_uploads.sql b/apps/backend/drizzle/0014_uploads.sql
index 8a717ef..7cf8624 100644
--- a/apps/backend/drizzle/0014_uploads.sql
+++ b/apps/backend/drizzle/0014_uploads.sql
@@ -7,12 +7,13 @@ CREATE TABLE `uploads` (
`range_start` integer DEFAULT 0 NOT NULL,
`requested_bytes` integer NOT NULL,
`sent_bytes` integer DEFAULT 0 NOT NULL,
+ `duration_ms` integer,
`status` text NOT NULL,
`error` text,
`started_at` text NOT NULL,
`updated_at` text NOT NULL,
`finished_at` text,
- CONSTRAINT "uploads_status_check" CHECK("uploads"."status" in ('uploading', 'completed', 'aborted', 'failed'))
+ CONSTRAINT "uploads_status_check" CHECK("uploads"."status" in ('uploading', 'completed', 'aborted', 'unconfirmed'))
);
--> statement-breakpoint
CREATE INDEX `uploads_status_idx` ON `uploads` (`status`);--> statement-breakpoint
diff --git a/apps/backend/drizzle/meta/0014_snapshot.json b/apps/backend/drizzle/meta/0014_snapshot.json
index f07d633..c098ab0 100644
--- a/apps/backend/drizzle/meta/0014_snapshot.json
+++ b/apps/backend/drizzle/meta/0014_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "330e5a4a-3d65-4303-b438-55329bcd8ca7",
+ "id": "f6d7a728-eaa3-4692-9836-71fd937cbe60",
"prevId": "7a0a25df-a2e9-4f9a-b477-42d28d9a01fc",
"tables": {
"api_keys": {
@@ -971,6 +971,13 @@
"autoincrement": false,
"default": 0
},
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"status": {
"name": "status",
"type": "text",
@@ -1029,7 +1036,7 @@
"checkConstraints": {
"uploads_status_check": {
"name": "uploads_status_check",
- "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'aborted', 'failed')"
+ "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'aborted', 'unconfirmed')"
}
}
}
diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json
index a4ef9a8..99faf14 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -103,7 +103,7 @@
{
"idx": 14,
"version": "6",
- "when": 1794172380544,
+ "when": 1794177717284,
"tag": "0014_uploads",
"breakpoints": true
}
diff --git a/apps/backend/src/__tests__/peer-download.test.ts b/apps/backend/src/__tests__/peer-download.test.ts
index 740d967..eaee098 100644
--- a/apps/backend/src/__tests__/peer-download.test.ts
+++ b/apps/backend/src/__tests__/peer-download.test.ts
@@ -151,6 +151,45 @@ describe('PeerConnector.downloadFile', () => {
}
})
+ test('asks for a receipt and posts one after the file arrives', async () => {
+ const receipts: unknown[] = []
+ const seen = { receiptHeader: null as string | null }
+ server.use(
+ http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, ({ request }) => {
+ seen.receiptHeader = request.headers.get('X-Jack-Receipt')
+ return new Response(streamOf([1, 2, 3, 4]), {
+ headers: { 'Content-Length': '4', 'X-Jack-Upload-Id': '42' },
+ })
+ }),
+ http.post(`${PEER_JACK_URL}/peer/uploads/:uploadId/receipt`, async ({ request, params }) => {
+ receipts.push({
+ uploadId: params.uploadId,
+ apiKey: request.headers.get('X-Api-Key'),
+ body: await request.json(),
+ })
+ return new HttpResponse(null, { status: 204 })
+ }),
+ )
+
+ const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ const dir = await mkdtemp(join(tmpdir(), 'jack-peer-receipt-'))
+ try {
+ await peer.downloadFile('remote1:movie:99', join(dir, 'Movie.mkv'), { releaseSize: 4 })
+ // Fire and forget: the download resolves without waiting for it.
+ await waitFor(async () => receipts.length === 1)
+ expect(seen.receiptHeader).toBe('1')
+ expect(receipts[0]).toMatchObject({
+ uploadId: '42',
+ apiKey: 'peer-api-key',
+ body: { bytes: 4 },
+ })
+ expect((receipts[0] as { body: { durationMs: number } }).body.durationMs).toBeGreaterThanOrEqual(0)
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
test('falls back to releaseSize for expected bytes when Content-Length is missing or invalid', async () => {
for (const contentLength of [null, 'not-a-number']) {
server.resetHandlers()
diff --git a/apps/backend/src/__tests__/upload-tracking.test.ts b/apps/backend/src/__tests__/upload-tracking.test.ts
index 5eb9292..1e8602b 100644
--- a/apps/backend/src/__tests__/upload-tracking.test.ts
+++ b/apps/backend/src/__tests__/upload-tracking.test.ts
@@ -1,4 +1,3 @@
-import type { UploadOutcome } from '../modules/uploads/counting-stream'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -7,106 +6,42 @@ import { Hono } from 'hono'
import { openMemoryDatabase } from '../database/connection'
import { PeerController } from '../modules/peer/peer.controller'
import { getPeerRouter } from '../modules/peer/peer.router'
-import { countBytes } from '../modules/uploads/counting-stream'
import { UploadsRepository } from '../modules/uploads/uploads.repository'
import { UploadsService } from '../modules/uploads/uploads.service'
-function recorder() {
- const progress: number[] = []
- const ends: { outcome: UploadOutcome, sentBytes: number, error: string | null }[] = []
- return {
- progress,
- ends,
- hooks: {
- onProgress: (sentBytes: number) => progress.push(sentBytes),
- onEnd: (outcome: UploadOutcome, sentBytes: number, error: string | null) => ends.push({ outcome, sentBytes, error }),
- },
- }
-}
-
-// A blob streamed in several chunks, so partial reads are possible.
-function chunkedBlob() {
- return new Blob([
- new Uint8Array(3),
- new Uint8Array(4),
- new Uint8Array(5),
- ])
+const input = {
+ peerName: 'alice',
+ itemId: 'radarr1:movie:1',
+ filename: 'Movie.mkv',
+ totalBytes: 12,
+ rangeStart: 0,
+ requestedBytes: 12,
}
-describe('countBytes', () => {
- test('passes the bytes through and ends as completed', async () => {
- const { progress, ends, hooks } = recorder()
- const body = await new Response(countBytes(new Blob([
- new Uint8Array([1, 2, 3, 4]),
- ]), hooks)).arrayBuffer()
-
- expect([
- ...new Uint8Array(body),
- ]).toEqual([1, 2, 3, 4])
- expect(progress.at(-1)).toBe(4)
- expect(ends).toEqual([
- { outcome: 'completed', sentBytes: 4, error: null },
- ])
- })
-
- test('ends as aborted when the consumer cancels', async () => {
- const { ends, hooks } = recorder()
- const reader = countBytes(chunkedBlob(), hooks).getReader()
- const first = await reader.read()
- await reader.cancel()
-
- expect(ends).toEqual([
- { outcome: 'aborted', sentBytes: first.value!.byteLength, error: 'peer disconnected' },
- ])
- })
-
- test('ends as aborted once when the request signal aborts', async () => {
- const { ends, hooks } = recorder()
+describe('UploadsService', () => {
+ test('a disconnect aborts the row; a finished response leaves it for the receipt', () => {
+ const repository = new UploadsRepository(openMemoryDatabase())
+ const service = new UploadsService(repository)
const controller = new AbortController()
- const stream = countBytes(chunkedBlob(), hooks, controller.signal)
- controller.abort()
- await stream.cancel().catch(() => {})
+ const dropped = service.start(input, true, controller.signal)
+ const finished = service.start(input, true, new AbortController().signal)
- expect(ends).toEqual([
- { outcome: 'aborted', sentBytes: 0, error: 'peer disconnected' },
- ])
- })
-
- test('ends as failed when the file cannot be read', async () => {
- const { ends, hooks } = recorder()
- const missing = Bun.file(join(tmpdir(), 'jack-missing-upload-file'))
- await expect(new Response(countBytes(missing, hooks)).arrayBuffer()).rejects.toThrow()
+ controller.abort()
- expect(ends).toHaveLength(1)
- expect(ends[0]!.outcome).toBe('failed')
+ expect(repository.get(dropped)).toMatchObject({ status: 'aborted', error: 'peer disconnected' })
+ expect(repository.get(finished)?.status).toBe('uploading')
+ expect(service.receipt(finished, 'alice', 12, 4000)).toBe(true)
+ expect(repository.get(finished)).toMatchObject({ status: 'completed', sentBytes: 12, durationMs: 4000 })
})
-})
-describe('UploadsService.track', () => {
- test('writes the row on finish and feeds the live tracker while running', async () => {
+ test('a peer that sends no receipt starts unconfirmed', () => {
const repository = new UploadsRepository(openMemoryDatabase())
- const service = new UploadsService(repository)
- const stream = service.track({
- peerName: 'alice',
- itemId: 'radarr1:movie:1',
- filename: 'Movie.mkv',
- totalBytes: 12,
- rangeStart: 0,
- requestedBytes: 12,
- }, chunkedBlob())
-
- const [running] = repository.list()
- expect(running?.status).toBe('uploading')
- expect(service.live.get(running!.id)?.downloadedBytes).toBe(0)
-
- await new Response(stream).arrayBuffer()
-
- expect(repository.get(running!.id)).toMatchObject({ status: 'completed', sentBytes: 12 })
- expect(service.live.get(running!.id)).toBeNull()
+ const id = new UploadsService(repository).start(input, false)
+ expect(repository.get(id)?.status).toBe('unconfirmed')
})
})
-describe('GET /peer/items/:id/file upload tracking', () => {
+describe('peer file route and receipt', () => {
let tempDir: string
let filePath: string
@@ -134,32 +69,54 @@ describe('GET /peer/items/:id/file upload tracking', () => {
return { app, repository }
}
- test('records a range request with its start and the bytes sent', async () => {
+ test('serves a range request, then the receipt completes its row', async () => {
const { app, repository } = setup()
const res = await app.request('/peer/items/remote1:movie:1/file', {
- headers: { Range: 'bytes=6-' },
+ headers: { 'Range': 'bytes=6-', 'X-Jack-Receipt': '1' },
})
expect([
...new Uint8Array(await res.arrayBuffer()),
]).toEqual([6, 7, 8, 9])
-
+ const uploadId = res.headers.get('X-Jack-Upload-Id')
expect(repository.list()).toEqual([
expect.objectContaining({
peerName: 'master',
- itemId: 'remote1:movie:1',
- filename: 'Movie.mkv',
totalBytes: 10,
rangeStart: 6,
requestedBytes: 4,
- sentBytes: 4,
- status: 'completed',
+ status: 'uploading',
}),
])
+
+ const receipt = await app.request(`/peer/uploads/${uploadId}/receipt`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ bytes: 4, durationMs: 2 }),
+ })
+ expect(receipt.status).toBe(204)
+ expect(repository.list()[0]).toMatchObject({ status: 'completed', sentBytes: 4, durationMs: 2 })
+ })
+
+ test('an unknown receipt id is ignored, a malformed body rejected', async () => {
+ const { app } = setup()
+ const unknown = await app.request('/peer/uploads/999/receipt', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ bytes: 4, durationMs: 2 }),
+ })
+ expect(unknown.status).toBe(204)
+ const malformed = await app.request('/peer/uploads/1/receipt', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ bytes: -1 }),
+ })
+ expect(malformed.status).toBe(400)
})
test('does not record a HEAD request', async () => {
const { app, repository } = setup()
- await app.request('/peer/items/remote1:movie:1/file', { method: 'HEAD' })
+ const res = await app.request('/peer/items/remote1:movie:1/file', { method: 'HEAD' })
+ expect(res.headers.get('X-Jack-Upload-Id')).toBeNull()
expect(repository.list()).toEqual([])
})
})
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
index 43f873a..ee2ba89 100644
--- a/apps/backend/src/__tests__/uploads-repository.test.ts
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -16,41 +16,67 @@ const input = {
}
describe('UploadsRepository', () => {
- test('creates an uploading row with nothing sent', () => {
- const row = makeRepo().create(input)
+ test('creates a row with nothing sent yet', () => {
+ const row = makeRepo().create(input, 'uploading')
expect(row).toMatchObject({
...input,
sentBytes: 0,
+ durationMs: null,
status: 'uploading',
error: null,
finishedAt: null,
})
})
- test('records progress and the final outcome', () => {
+ test('a receipt from the same peer completes it with bytes and duration', () => {
const repo = makeRepo()
- const { id } = repo.create(input)
+ const { id } = repo.create(input, 'uploading')
- repo.updateProgress(id, 20)
- expect(repo.get(id)?.sentBytes).toBe(20)
-
- repo.finish(id, 'aborted', 30, 'peer disconnected')
- expect(repo.get(id)).toMatchObject({ status: 'aborted', sentBytes: 30, error: 'peer disconnected' })
+ expect(repo.complete(id, 'mallory', 60, 1000)).toBe(false)
+ expect(repo.complete(id, 'alice', 60, 2000)).toBe(true)
+ expect(repo.get(id)).toMatchObject({ status: 'completed', sentBytes: 60, durationMs: 2000 })
expect(repo.get(id)?.finishedAt).not.toBeNull()
+ // Closed rows stay closed.
+ expect(repo.complete(id, 'alice', 1, 1)).toBe(false)
+ })
+
+ test('an older peer\'s row can still be completed or aborted', () => {
+ const repo = makeRepo()
+ const a = repo.create(input, 'unconfirmed')
+ const b = repo.create(input, 'unconfirmed')
+ repo.markAborted(a.id, 'peer disconnected')
+ expect(repo.complete(b.id, 'alice', 60, 10)).toBe(true)
+ expect(repo.get(a.id)?.status).toBe('aborted')
+ })
+
+ test('a disconnect after completion changes nothing', () => {
+ const repo = makeRepo()
+ const { id } = repo.create(input, 'uploading')
+ repo.complete(id, 'alice', 60, 10)
+ repo.markAborted(id, 'peer disconnected')
+ expect(repo.get(id)?.status).toBe('completed')
+ })
+
+ test('expires rows that waited too long for a receipt', () => {
+ const repo = makeRepo()
+ const { id } = repo.create(input, 'uploading')
+ expect(repo.expireUnconfirmed('2000-01-01T00:00:00Z')).toBe(0)
+ expect(repo.expireUnconfirmed('9999-01-01T00:00:00Z')).toBe(1)
+ expect(repo.get(id)?.status).toBe('unconfirmed')
})
test('lists newest first', () => {
const repo = makeRepo()
- const first = repo.create(input)
- const second = repo.create(input)
+ const first = repo.create(input, 'uploading')
+ const second = repo.create(input, 'uploading')
expect(repo.list().map(r => r.id)).toEqual([second.id, first.id])
})
test('marks rows left uploading by a previous run as aborted', () => {
const repo = makeRepo()
- const running = repo.create(input)
- const done = repo.create(input)
- repo.finish(done.id, 'completed', 60)
+ const running = repo.create(input, 'uploading')
+ const done = repo.create(input, 'uploading')
+ repo.complete(done.id, 'alice', 60, 10)
expect(repo.reconcileInterrupted()).toBe(1)
expect(repo.get(running.id)).toMatchObject({ status: 'aborted', error: 'interrupted by a jack restart' })
@@ -61,9 +87,8 @@ describe('UploadsRepository', () => {
const repo = makeRepo()
let calls = 0
repo.subscribe(() => calls++)
- const { id } = repo.create(input)
- repo.updateProgress(id, 1)
- repo.finish(id, 'completed', 60)
- expect(calls).toBe(3)
+ const { id } = repo.create(input, 'uploading')
+ repo.complete(id, 'alice', 60, 10)
+ expect(calls).toBe(2)
})
})
diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts
index 98f396b..598c4c5 100644
--- a/apps/backend/src/database/schema.ts
+++ b/apps/backend/src/database/schema.ts
@@ -84,11 +84,16 @@ export const apiKeys = sqliteTable('api_keys', {
// Files peers pull from this instance through `/peer/items/:id/file`. One row
// per HTTP request: a resumed transfer sends a Range header and gets its own
// row, starting at `rangeStart`.
+//
+// Files go out zero-copy, so jack never counts the bytes itself. It learns an
+// upload ended early from the disconnect (`aborted`), and that it finished from
+// the receipt the downloading jack posts back (`completed`, with its duration).
+// No receipt (an older peer, or a lost POST) leaves it `unconfirmed`.
export const UPLOAD_STATUSES = [
'uploading',
'completed',
'aborted',
- 'failed',
+ 'unconfirmed',
] as const
export type UploadStatus = typeof UPLOAD_STATUSES[number]
@@ -104,14 +109,17 @@ export const uploads = sqliteTable('uploads', {
rangeStart: integer('range_start').notNull().default(0),
// Bytes this request was asked to send (the response Content-Length).
requestedBytes: integer('requested_bytes').notNull(),
+ // Bytes the peer says it received; 0 until its receipt arrives.
sentBytes: integer('sent_bytes').notNull().default(0),
+ // Transfer time the peer reported; null without a receipt, so speed is unknown.
+ durationMs: integer('duration_ms'),
status: text('status').$type().notNull(),
error: text('error'),
startedAt: text('started_at').notNull(),
updatedAt: text('updated_at').notNull(),
finishedAt: text('finished_at'),
}, t => [
- check('uploads_status_check', sql`${t.status} in ('uploading', 'completed', 'aborted', 'failed')`),
+ check('uploads_status_check', sql`${t.status} in ('uploading', 'completed', 'aborted', 'unconfirmed')`),
index('uploads_status_idx').on(t.status),
index('uploads_started_at_idx').on(t.startedAt),
])
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index 3259c91..17072dc 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -59,6 +59,8 @@ const uploadsService = new UploadsService(new UploadsRepository(database.db))
const interruptedUploads = uploadsService.repository.reconcileInterrupted()
if (interruptedUploads > 0)
logger.warn({ uploads: interruptedUploads }, 'Marked uploads interrupted by the previous jack run as aborted')
+// A receipt that never came (a lost POST) shouldn't leave an upload running forever.
+setInterval(() => uploadsService.expireUnconfirmed(), 60 * 60 * 1000).unref()
// Seed the management service from the shared raw object returned by getAppConfig
// so the service's persisted state can never diverge from the loaded runtime config.
diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts
index 4bedcd0..a0dba21 100644
--- a/apps/backend/src/lib/servers/peer.ts
+++ b/apps/backend/src/lib/servers/peer.ts
@@ -258,6 +258,40 @@ export class PeerConnector extends ServerConnector {
}
@requiresInitialization
+ /**
+ * Tell the serving jack this file arrived and how long it took. It sends files
+ * zero-copy, so it can see an early disconnect but not the end of a transfer.
+ * Fire and forget: a lost receipt only leaves that upload's speed unknown, and
+ * a peer too old to issue upload ids gets none.
+ */
+ private sendUploadReceipt(uploadId: string | null, bytes: number, durationMs: number): void {
+ if (!uploadId || !/^\d+$/.test(uploadId))
+ return
+ const url = new URL(`/peer/uploads/${uploadId}/receipt`, this.url)
+ fetch(url, {
+ method: 'POST',
+ headers: { ...this.headers, 'X-Api-Key': this.apiKey, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ bytes,
+ durationMs: Math.round(durationMs),
+ }),
+ signal: AbortSignal.timeout(10_000),
+ })
+ .then((response) => {
+ if (!response.ok)
+ logger.debug({ peer: this.name, uploadId, status: response.status }, 'Peer did not accept the upload receipt')
+ })
+ .catch((err) => {
+ logger.debug({
+ peer: this.name,
+ uploadId,
+ error: err instanceof Error
+ ? err.message
+ : String(err),
+ }, 'Could not send the upload receipt')
+ })
+ }
+
async downloadFile(id: string, destPath: string, options: PeerDownloadOptions = {}): Promise {
return withSpan('peer.download_file', {
'peer.name': this.name,
@@ -269,7 +303,8 @@ export class PeerConnector extends ServerConnector {
const torrentFilename = options.torrentFilename
const url = new URL(`/peer/items/${encodeURIComponent(id)}/file`, this.url)
const partPath = options.partPath ?? `${destPath}.part`
- const baseHeaders = { ...this.headers, 'X-Api-Key': this.apiKey }
+ // X-Jack-Receipt: we'll report back when the file arrived (see sendUploadReceipt).
+ const baseHeaders = { ...this.headers, 'X-Api-Key': this.apiKey, 'X-Jack-Receipt': '1' }
setSpanAttributes(span, { 'http.request.idle_timeout_ms': idleTimeoutMs, 'url.path': url.pathname })
// Idle (inactivity) timeout, armed ONLY around network waits (fetch + each
@@ -499,6 +534,9 @@ export class PeerConnector extends ServerConnector {
throw err
}
let downloadedBytes = existingBytes
+ // What this request carries, for the receipt: a resume only counts its range.
+ const requestStartBytes = existingBytes
+ const requestStartedAt = performance.now()
let lastLoggedAt = Date.now()
let lastLoggedBytes = downloadedBytes
let lastLiveAt = 0
@@ -591,6 +629,7 @@ export class PeerConnector extends ServerConnector {
signal.throwIfAborted()
await withFsContext('move_completed', 'moving the completed .part into place', [partPath, destPath], () => rename(partPath, destPath))
setSpanAttribute(span, 'download.downloaded_bytes', downloadedBytes)
+ this.sendUploadReceipt(response.headers.get('X-Jack-Upload-Id'), downloadedBytes - requestStartBytes, performance.now() - requestStartedAt)
try {
await options.onProgress?.({ type: 'completed', downloadedBytes, expectedBytes })
}
diff --git a/apps/backend/src/modules/peer/peer.controller.ts b/apps/backend/src/modules/peer/peer.controller.ts
index caa3d94..89853ec 100644
--- a/apps/backend/src/modules/peer/peer.controller.ts
+++ b/apps/backend/src/modules/peer/peer.controller.ts
@@ -39,9 +39,9 @@ export function parseRangeHeader(value: string | undefined | null): { start?: nu
// Handing the BunFile straight to `new Response` lets Bun.serve stream it with native
// backpressure (sendfile): if the consumer stalls or aborts mid-download, we stop
// reading from disk instead of buffering the whole file into RAM. Pumping `.stream()`
-// ourselves does NOT backpressure and lets one stalled peer OOM the process.
-// Upload tracking (uploads/counting-stream.ts) wraps this body in a pull-only
-// stream that reads a chunk only when the socket asks for one, which keeps it.
+// ourselves does NOT backpressure and lets one stalled peer OOM the process. It is
+// also why upload tracking never touches the body: the downloading jack reports
+// the bytes and duration back instead (see UploadsService).
export type StreamFileResult
= | { type: 'full', body: Blob, size: number, filename: string }
| { type: 'partial', body: Blob, size: number, totalSize: number, start: number, end: number, filename: string }
diff --git a/apps/backend/src/modules/peer/peer.router.ts b/apps/backend/src/modules/peer/peer.router.ts
index fa3ae2d..6080e2b 100644
--- a/apps/backend/src/modules/peer/peer.router.ts
+++ b/apps/backend/src/modules/peer/peer.router.ts
@@ -14,6 +14,11 @@ function safeFilename(name: string): string {
return name.replace(UNSAFE_FILENAME_CHARS, '')
}
+const UploadReceipt = z.object({
+ bytes: z.number().int().min(0),
+ durationMs: z.number().int().min(0),
+})
+
export function getPeerRouter(controller: PeerController, uploads?: UploadsService) {
const app = new Hono<{ Variables: AuthVariables }>()
@@ -134,10 +139,10 @@ export function getPeerRouter(controller: PeerController, uploads?: UploadsServi
})
}
- // Count what the peer actually reads. A HEAD request sends no body, so it is
- // not an upload. The master key sets no name.
- const body = uploads && c.req.method !== 'HEAD'
- ? uploads.track({
+ // Record the upload; the body still goes out zero-copy. A HEAD request sends
+ // no body, so it is not an upload. The master key sets no name.
+ const uploadId = uploads && c.req.method !== 'HEAD'
+ ? uploads.start({
peerName: c.get('apiKeyName') ?? 'master',
itemId,
filename: result.filename,
@@ -148,11 +153,17 @@ export function getPeerRouter(controller: PeerController, uploads?: UploadsServi
? result.start
: 0,
requestedBytes: result.size,
- }, result.body, c.req.raw.signal)
- : result.body
+ }, c.req.header('X-Jack-Receipt') === '1', c.req.raw.signal)
+ : undefined
+ // Where the downloading jack posts its receipt (see /uploads/:id/receipt).
+ const uploadHeader: Record = uploadId === undefined
+ ? {}
+ : {
+ 'X-Jack-Upload-Id': String(uploadId),
+ }
if (result.type === 'partial') {
- return new Response(body, {
+ return new Response(result.body, {
status: 206,
headers: {
'Content-Type': 'application/octet-stream',
@@ -160,19 +171,40 @@ export function getPeerRouter(controller: PeerController, uploads?: UploadsServi
'Content-Range': `bytes ${result.start}-${result.end}/${result.totalSize}`,
'Accept-Ranges': 'bytes',
'Content-Disposition': `attachment; filename="${safeFilename(result.filename)}"`,
+ ...uploadHeader,
},
})
}
- return new Response(body, {
+ return new Response(result.body, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(result.size),
'Accept-Ranges': 'bytes',
'Content-Disposition': `attachment; filename="${result.filename}"`,
+ ...uploadHeader,
},
})
})
+ app.post('/uploads/:uploadId/receipt', describeRoute({
+ tags: ['Peer'],
+ summary: 'Report a finished download',
+ description: 'Sent by the downloading jack after a file from `/peer/items/:itemId/file` arrived, with the `X-Jack-Upload-Id` that response carried. Records how many bytes the request delivered and how long it took. Always answers 204, so a stale or unknown id is not an error worth retrying.',
+ security: [
+ { 'X-Api-Key': [] },
+ ],
+ responses: {
+ 204: { description: 'Receipt taken (or ignored)' },
+ 400: { description: 'Malformed receipt' },
+ },
+ }), zValidator('json', UploadReceipt), (c) => {
+ const uploadId = Number(c.req.param('uploadId'))
+ const { bytes, durationMs } = c.req.valid('json')
+ if (uploads && Number.isSafeInteger(uploadId))
+ uploads.receipt(uploadId, c.get('apiKeyName') ?? 'master', bytes, durationMs)
+ return c.body(null, 204)
+ })
+
return app
}
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 7940071..4580c58 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -57,22 +57,17 @@ export function enrichDownload(d: DownloadRecord, live?: LiveProgressTracker) {
}
}
-export function enrichUpload(u: UploadRecord, live?: LiveProgressTracker) {
- // The row is written every 10 s; a running upload has a fresher count in memory.
- const current = u.status === 'uploading'
- ? live?.get(u.id)
+export function enrichUpload(u: UploadRecord) {
+ // Files go out zero-copy, so only a receipt tells us how far it got. Progress is
+ // through the whole file, so a resumed request starts where the range does.
+ const progress = u.status === 'completed' && u.totalBytes > 0
+ ? Math.min(1, (u.rangeStart + u.sentBytes) / u.totalBytes)
: null
- const sentBytes = Math.max(u.sentBytes, current?.downloadedBytes ?? 0)
- // Progress through the whole file, so a resumed request starts where the range does.
- const progress = u.totalBytes > 0
- ? Math.min(1, (u.rangeStart + sentBytes) / u.totalBytes)
+ // Average over the whole transfer, from the receipt; unknown without one.
+ const speedBps = u.durationMs && u.durationMs > 0
+ ? (u.sentBytes * 1000) / u.durationMs
: null
- return {
- ...u,
- sentBytes,
- progress,
- speedBps: current?.speedBps ?? null,
- }
+ return { ...u, progress, speedBps }
}
export class StatusController {
@@ -82,7 +77,7 @@ export class StatusController {
private readonly uploads?: UploadsService,
) {}
- private enrichUpload = (u: UploadRecord) => enrichUpload(u, this.uploads?.live)
+ private enrichUpload = (u: UploadRecord) => enrichUpload(u)
private get live(): LiveProgressTracker | undefined {
return this.downloads && liveProgressFor(this.downloads)
diff --git a/apps/backend/src/modules/uploads/counting-stream.ts b/apps/backend/src/modules/uploads/counting-stream.ts
deleted file mode 100644
index ac9de33..0000000
--- a/apps/backend/src/modules/uploads/counting-stream.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-export type UploadOutcome = 'completed' | 'aborted' | 'failed'
-
-export interface ByteCountingHooks {
- /** Called after every chunk with the running total. */
- onProgress: (sentBytes: number) => void
- /** Called exactly once, when the body was fully read, cancelled, or failed. */
- onEnd: (outcome: UploadOutcome, sentBytes: number, error: string | null) => void
-}
-
-function errorMessage(err: unknown): string {
- return err instanceof Error
- ? err.message
- : String(err)
-}
-
-/**
- * Wraps a file body in a stream that counts the bytes handed to the consumer.
- *
- * The stream is pull-based with a zero high-water mark: a chunk is read from
- * disk only when the consumer asks for one, so a stalled peer still holds the
- * read back instead of buffering the file in memory. A cancel from the consumer
- * or an abort on `signal` (the peer disconnected) ends it as `aborted`.
- */
-export function countBytes(body: Blob, hooks: ByteCountingHooks, signal?: AbortSignal): ReadableStream {
- // Opened on the first pull, so a file that vanished since the existence check
- // fails the stream (and ends as `failed`) instead of throwing here.
- const openReader = () => body.stream().getReader()
- let reader: ReturnType | undefined
- let sentBytes = 0
- let ended = false
-
- const end = (outcome: UploadOutcome, error: string | null = null) => {
- if (ended)
- return
- ended = true
- signal?.removeEventListener('abort', onAbort)
- hooks.onEnd(outcome, sentBytes, error)
- }
-
- function onAbort() {
- end('aborted', 'peer disconnected')
- reader?.cancel().catch(() => {})
- }
-
- if (signal?.aborted)
- onAbort()
- else
- signal?.addEventListener('abort', onAbort, { once: true })
-
- return new ReadableStream({
- async pull(controller) {
- try {
- reader ??= openReader()
- const { done, value } = await reader.read()
- if (done) {
- controller.close()
- end('completed')
- return
- }
- sentBytes += value.byteLength
- controller.enqueue(value)
- hooks.onProgress(sentBytes)
- }
- catch (err) {
- end('failed', errorMessage(err))
- controller.error(err)
- }
- },
- async cancel(reason) {
- end('aborted', reason === undefined
- ? 'peer disconnected'
- : errorMessage(reason))
- await reader?.cancel(reason).catch(() => {})
- },
- }, { highWaterMark: 0 })
-}
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
index 12412ef..93df261 100644
--- a/apps/backend/src/modules/uploads/uploads.repository.ts
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -1,6 +1,6 @@
import type { AppDatabase } from '../../database/connection'
-import type { NewUploadRow, UploadRow, UploadStatus } from '../../database/schema'
-import { desc, eq } from 'drizzle-orm'
+import type { NewUploadRow, UploadRow } from '../../database/schema'
+import { and, desc, eq, inArray, lt } from 'drizzle-orm'
import { uploads } from '../../database/schema'
export type UploadRecord = UploadRow
@@ -14,6 +14,9 @@ export interface CreateUploadInput {
requestedBytes: number
}
+// Statuses an upload can still leave: a receipt or a disconnect may yet arrive.
+const OPEN = ['uploading', 'unconfirmed'] as const
+
function nowIso() {
return new Date().toISOString()
}
@@ -43,12 +46,13 @@ export class UploadsRepository {
}
}
- create(input: CreateUploadInput): UploadRecord {
+ /** `uploading` when the peer promised a receipt, `unconfirmed` when it can't send one. */
+ create(input: CreateUploadInput, status: 'uploading' | 'unconfirmed'): UploadRecord {
const timestamp = nowIso()
const values: NewUploadRow = {
...input,
sentBytes: 0,
- status: 'uploading',
+ status,
startedAt: timestamp,
updatedAt: timestamp,
}
@@ -66,30 +70,60 @@ export class UploadsRepository {
return this.db.select().from(uploads).orderBy(desc(uploads.startedAt), desc(uploads.id)).all()
}
- updateProgress(id: number, sentBytes: number): void {
- this.db.update(uploads)
+ /** The peer disconnected before the body was sent. No-op once the row has ended. */
+ markAborted(id: number, error: string): void {
+ const timestamp = nowIso()
+ const changed = this.db.update(uploads)
.set({
- sentBytes,
- updatedAt: nowIso(),
+ status: 'aborted',
+ error,
+ updatedAt: timestamp,
+ finishedAt: timestamp,
})
- .where(eq(uploads.id, id))
- .run()
- this.notifyChanged()
+ .where(and(eq(uploads.id, id), inArray(uploads.status, [...OPEN])))
+ .returning({ id: uploads.id })
+ .all()
+ if (changed.length > 0)
+ this.notifyChanged()
}
- finish(id: number, status: Exclude, sentBytes: number, error: string | null = null): void {
+ /**
+ * The downloading peer's receipt: it got `sentBytes` in `durationMs`. Only the
+ * peer the row belongs to can close it, and only while it is still open.
+ * Returns whether the row was updated.
+ */
+ complete(id: number, peerName: string, sentBytes: number, durationMs: number): boolean {
const timestamp = nowIso()
- this.db.update(uploads)
+ const changed = this.db.update(uploads)
.set({
- status,
+ status: 'completed',
sentBytes,
- error,
+ durationMs,
+ error: null,
updatedAt: timestamp,
finishedAt: timestamp,
})
- .where(eq(uploads.id, id))
- .run()
- this.notifyChanged()
+ .where(and(eq(uploads.id, id), eq(uploads.peerName, peerName), inArray(uploads.status, [...OPEN])))
+ .returning({ id: uploads.id })
+ .all()
+ if (changed.length > 0)
+ this.notifyChanged()
+ return changed.length > 0
+ }
+
+ /** Rows still waiting for a receipt that started before `before`: it isn't coming. */
+ expireUnconfirmed(before: string): number {
+ const rows = this.db.update(uploads)
+ .set({
+ status: 'unconfirmed',
+ updatedAt: nowIso(),
+ })
+ .where(and(eq(uploads.status, 'uploading'), lt(uploads.startedAt, before)))
+ .returning({ id: uploads.id })
+ .all()
+ if (rows.length > 0)
+ this.notifyChanged()
+ return rows.length
}
/** Rows still `uploading` from a previous run lost their connection with it. */
diff --git a/apps/backend/src/modules/uploads/uploads.service.ts b/apps/backend/src/modules/uploads/uploads.service.ts
index 99ee2b5..8d27caf 100644
--- a/apps/backend/src/modules/uploads/uploads.service.ts
+++ b/apps/backend/src/modules/uploads/uploads.service.ts
@@ -1,52 +1,35 @@
import type { CreateUploadInput, UploadRecord, UploadsRepository } from './uploads.repository'
-import { logger } from '../../logger'
-import { LiveProgressTracker } from '../downloads/live-progress'
-import { countBytes } from './counting-stream'
-// The row is written this often while a file streams (and once at the end).
-const PERSIST_INTERVAL_MS = 10_000
-// The live tracker gets a sample this often, enough for a smooth speed reading.
-const LIVE_INTERVAL_MS = 1000
+// A receipt that hasn't come after this long won't: the row goes `unconfirmed`.
+const RECEIPT_WAIT_MS = 48 * 60 * 60 * 1000
/**
- * Records files peers pull from `/peer/items/:id/file`. Each request gets an
- * `uploads` row; the bytes are counted as the peer reads them, persisted every
- * 10 s, and fed to an in-memory tracker about once a second for live speed.
+ * Records files peers pull from `/peer/items/:id/file`. The file itself is sent
+ * zero-copy, untouched; this only keeps the row. A disconnect marks it aborted,
+ * and the downloading jack's receipt (`/peer/uploads/:id/receipt`) completes it
+ * with the bytes and time the transfer took.
*/
export class UploadsService {
- readonly live: LiveProgressTracker
-
- constructor(
- readonly repository: UploadsRepository,
- private readonly now: () => number = Date.now,
- ) {
- this.live = new LiveProgressTracker(now)
+ constructor(readonly repository: UploadsRepository) {}
+
+ /**
+ * Starts a row for this request and returns its id, for the `X-Jack-Upload-Id`
+ * response header. `expectsReceipt` is whether the peer said it will report back.
+ */
+ start(input: CreateUploadInput, expectsReceipt: boolean, signal?: AbortSignal): number {
+ const upload = this.repository.create(input, expectsReceipt ? 'uploading' : 'unconfirmed')
+ // Bun aborts the request signal only when the peer disconnects before the
+ // whole body went out; a finished response never fires it.
+ signal?.addEventListener('abort', () => this.repository.markAborted(upload.id, 'peer disconnected'), { once: true })
+ return upload.id
}
- /** Starts a row for this request and returns the body to send instead of `body`. */
- track(input: CreateUploadInput, body: Blob, signal?: AbortSignal): ReadableStream {
- const upload = this.repository.create(input)
- let lastPersist = this.now()
- let lastSample = lastPersist
- this.live.record(upload.id, 0)
+ receipt(id: number, peerName: string, sentBytes: number, durationMs: number): boolean {
+ return this.repository.complete(id, peerName, sentBytes, durationMs)
+ }
- return countBytes(body, {
- onProgress: (sentBytes) => {
- const t = this.now()
- if (t - lastSample >= LIVE_INTERVAL_MS) {
- lastSample = t
- this.live.record(upload.id, sentBytes)
- }
- if (t - lastPersist >= PERSIST_INTERVAL_MS) {
- lastPersist = t
- this.write(upload.id, () => this.repository.updateProgress(upload.id, sentBytes))
- }
- },
- onEnd: (outcome, sentBytes, error) => {
- this.live.clear(upload.id)
- this.write(upload.id, () => this.repository.finish(upload.id, outcome, sentBytes, error))
- },
- }, signal)
+ expireUnconfirmed(now = Date.now()): number {
+ return this.repository.expireUnconfirmed(new Date(now - RECEIPT_WAIT_MS).toISOString())
}
list(): UploadRecord[] {
@@ -54,23 +37,6 @@ export class UploadsService {
}
subscribe(subscriber: () => void): () => void {
- const unsubscribers = [
- this.repository.subscribe(subscriber),
- this.live.subscribe(subscriber),
- ]
- return () => {
- for (const unsubscribe of unsubscribers)
- unsubscribe()
- }
- }
-
- // Bookkeeping must never break the transfer it describes.
- private write(id: number, fn: () => void): void {
- try {
- fn()
- }
- catch (err) {
- logger.warn({ uploadId: id, err }, 'Failed to record upload progress')
- }
+ return this.repository.subscribe(subscriber)
}
}
diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue
index ba5e9bd..691bbf3 100644
--- a/apps/ui/app/pages/index.vue
+++ b/apps/ui/app/pages/index.vue
@@ -157,14 +157,9 @@ function serverRole(server: { source: boolean, destination: boolean }) {
to {{ u.peerName }}
-
-
-
- {{ formatBytes(u.rangeStart + u.sentBytes) }} / {{ formatBytes(u.totalBytes) }}
-
-
-
- {{ formatTransferRate(u.speedBps, null) }}
+
+
+ Sending {{ formatBytes(u.requestedBytes) }} of {{ formatBytes(u.totalBytes) }} · started {{ formatAgo(u.startedAt) }} ago
diff --git a/apps/ui/app/pages/uploads.vue b/apps/ui/app/pages/uploads.vue
index 7332a8b..8bd9315 100644
--- a/apps/ui/app/pages/uploads.vue
+++ b/apps/ui/app/pages/uploads.vue
@@ -17,14 +17,19 @@ const statusBadge: Record {
uploading: all.filter(u => u.status === 'uploading').length,
completed: all.filter(u => u.status === 'completed').length,
aborted: all.filter(u => u.status === 'aborted').length,
- failed: all.filter(u => u.status === 'failed').length,
+ unconfirmed: all.filter(u => u.status === 'unconfirmed').length,
}
})
const chips: { key: StatusFilter, label: string, color: BadgeProps['color'] }[] = [
@@ -44,7 +49,7 @@ const chips: { key: StatusFilter, label: string, color: BadgeProps['color'] }[]
{ key: 'uploading', label: 'Uploading', color: 'primary' },
{ key: 'completed', label: 'Completed', color: 'success' },
{ key: 'aborted', label: 'Aborted', color: 'warning' },
- { key: 'failed', label: 'Failed', color: 'error' },
+ { key: 'unconfirmed', label: 'Unconfirmed', color: 'neutral' },
]
const search = ref('')
@@ -232,7 +237,7 @@ const columns: TableColumn[] = [
resumed at {{ formatBytes(row.original.rangeStart) }}
-
+
{{ row.original.error }}
@@ -247,15 +252,15 @@ const columns: TableColumn[] = [
-
-
- {{ formatTransferRate(row.original.speedBps, etaSeconds(row.original)) }}
+
+
+ {{ speedLabel(row.original) }}
- {{ formatBytes(row.original.sentBytes) }}
+ {{ row.original.status === 'completed' ? formatBytes(row.original.sentBytes) : '—' }}
@@ -293,19 +298,19 @@ const columns: TableColumn[] = [
{{ u.filename }}
-
+
{{ u.error }}
-
-
- {{ formatTransferRate(u.speedBps, etaSeconds(u)) }}
+
+
+ {{ speedLabel(u) }}
{{ u.peerName }}
- {{ formatBytes(u.sentBytes) }} of {{ formatBytes(u.totalBytes) }} · {{ formatAgo(u.startedAt) }} ago
+ {{ formatBytes(u.requestedBytes) }} of {{ formatBytes(u.totalBytes) }} · {{ formatAgo(u.startedAt) }} ago
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts
index 2ae1852..ea93759 100644
--- a/apps/ui/app/types/management.ts
+++ b/apps/ui/app/types/management.ts
@@ -98,12 +98,17 @@ export interface UploadItem {
totalBytes: number
rangeStart: number
requestedBytes: number
+ // From the downloading jack's receipt; 0 until it arrives.
sentBytes: number
- // Through the whole file, counting the range start.
+ // Transfer time from the receipt; null without one.
+ durationMs: number | null
+ // Through the whole file, counting the range start. Null until completed.
progress: number | null
- // Live, while uploading. Null otherwise.
+ // Average over the whole transfer, from the receipt. Null without one.
speedBps: number | null
- status: 'uploading' | 'completed' | 'aborted' | 'failed'
+ // Files go out zero-copy: `completed` needs the peer's receipt, and a peer that
+ // sends none (an older jack, or a lost receipt) leaves it `unconfirmed`.
+ status: 'uploading' | 'completed' | 'aborted' | 'unconfirmed'
error: string | null
startedAt: string
updatedAt: string
diff --git a/website/guide/management-ui.md b/website/guide/management-ui.md
index f86e59f..24778b6 100644
--- a/website/guide/management-ui.md
+++ b/website/guide/management-ui.md
@@ -42,10 +42,22 @@ The ***Uploads*** page lists them newest first, read-only, with:
- **Peer**: the name of the API key the peer used, or `master` for the master
key.
- **File**: the file name, with its size on disk.
-- **Sent**: bytes the peer has read so far, with live speed while it runs.
-- **Status**: `uploading`, `completed`, `aborted` when the peer disconnected
- or jack restarted mid-transfer, or `failed` when jack could not read the
- file.
+- **Sent**: bytes the peer received, with the average speed and how long it
+ took.
+- **Status**:
+ - `uploading` while the file is being sent.
+ - `completed` once the peer confirmed it got the file.
+ - `aborted` when the peer disconnected or jack restarted mid-transfer.
+ - `unconfirmed` when the peer never confirmed. Its size and speed stay
+ unknown.
+
+jack sends files straight from disk without reading them itself, which keeps
+uploads cheap but means it can't see a transfer finish. The downloading jack
+reports back instead: once the file arrives, it sends a receipt with the bytes
+it got and the time it took. Peers on versions without receipts, or a receipt
+that gets lost, leave the upload `unconfirmed`, and an upload still waiting for
+its receipt after 48 hours becomes `unconfirmed` too. There is no live speed
+while a file is being sent.
Each file request is its own row. A peer that resumes a transfer sends a
`Range` request, so the resume shows as a new row whose progress starts where
From d0f2b89ee40d1f047da7bf2431d20cd5439c3344 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 00:28:02 +0100
Subject: [PATCH 09/18] fix: address review feedback on failed-download cleanup
and upload tracking
---
apps/backend/drizzle/0014_uploads.sql | 1 +
apps/backend/drizzle/meta/0014_snapshot.json | 9 +-
apps/backend/drizzle/meta/_journal.json | 2 +-
.../__tests__/failed-download-reaper.test.ts | 29 +++++-
.../src/__tests__/live-progress.test.ts | 7 ++
.../src/__tests__/upload-tracking.test.ts | 23 ++++-
.../src/__tests__/uploads-repository.test.ts | 17 ++--
apps/backend/src/database/schema.ts | 4 +
apps/backend/src/index.ts | 11 ++-
apps/backend/src/lib/servers/peer.ts | 2 +-
apps/backend/src/middleware/require-auth.ts | 5 +
.../modules/downloads/downloads.service.ts | 8 +-
.../downloads/failed-download-reaper.ts | 24 +++--
.../src/modules/downloads/live-progress.ts | 11 ++-
apps/backend/src/modules/peer/peer.router.ts | 3 +-
.../src/modules/uploads/uploads.repository.ts | 14 ++-
.../src/modules/uploads/uploads.service.ts | 33 +++++--
apps/ui/app/pages/downloads.vue | 94 ++++++++++---------
website/guide/management-ui.md | 4 +-
19 files changed, 212 insertions(+), 89 deletions(-)
diff --git a/apps/backend/drizzle/0014_uploads.sql b/apps/backend/drizzle/0014_uploads.sql
index 7cf8624..7887b20 100644
--- a/apps/backend/drizzle/0014_uploads.sql
+++ b/apps/backend/drizzle/0014_uploads.sql
@@ -1,6 +1,7 @@
CREATE TABLE `uploads` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`peer_name` text NOT NULL,
+ `peer_key_id` integer,
`item_id` text NOT NULL,
`filename` text NOT NULL,
`total_bytes` integer NOT NULL,
diff --git a/apps/backend/drizzle/meta/0014_snapshot.json b/apps/backend/drizzle/meta/0014_snapshot.json
index c098ab0..5ca2f7c 100644
--- a/apps/backend/drizzle/meta/0014_snapshot.json
+++ b/apps/backend/drizzle/meta/0014_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "f6d7a728-eaa3-4692-9836-71fd937cbe60",
+ "id": "c9f970ae-d2a3-41bd-b206-35a480a193bf",
"prevId": "7a0a25df-a2e9-4f9a-b477-42d28d9a01fc",
"tables": {
"api_keys": {
@@ -927,6 +927,13 @@
"notNull": true,
"autoincrement": false
},
+ "peer_key_id": {
+ "name": "peer_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"item_id": {
"name": "item_id",
"type": "text",
diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json
index 99faf14..b475d6d 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -103,7 +103,7 @@
{
"idx": 14,
"version": "6",
- "when": 1794177717284,
+ "when": 1794180331966,
"tag": "0014_uploads",
"breakpoints": true
}
diff --git a/apps/backend/src/__tests__/failed-download-reaper.test.ts b/apps/backend/src/__tests__/failed-download-reaper.test.ts
index da39432..951d69b 100644
--- a/apps/backend/src/__tests__/failed-download-reaper.test.ts
+++ b/apps/backend/src/__tests__/failed-download-reaper.test.ts
@@ -21,15 +21,19 @@ function setup(rows: DownloadRecord[], options: { enabled?: boolean, retryFails?
const calls: string[] = []
const reaper = new FailedDownloadReaper({
enabled: () => options.enabled ?? true,
- list: () => rows,
+ list: () => [...rows],
+ get: id => rows.find(row => row.id === id) ?? null,
retry: async (id) => {
calls.push(`retry:${id}`)
if (options.retryFails)
throw new Error('nope')
},
- remove: async (id) => {
+ remove: async (id, onlyIf) => {
+ const index = rows.findIndex(row => row.id === id)
+ if (!onlyIf(rows[index]!))
+ return
calls.push(`remove:${id}`)
- rows.splice(rows.findIndex(row => row.id === id), 1)
+ rows.splice(index, 1)
},
maxRetries: 3,
baseDelayMs: 100,
@@ -39,6 +43,25 @@ function setup(rows: DownloadRecord[], options: { enabled?: boolean, retryFails?
return { reaper, clock, calls }
}
+test('re-reads each row, so one the user cancelled mid-pass is left alone', async () => {
+ const second = failedRow({ id: 2, lastOperation: 'import' })
+ const rows = [
+ failedRow({ id: 1, lastOperation: 'import' }),
+ second,
+ ]
+ const { reaper, calls } = setup(rows)
+ // The pass lists both rows, then the user cancels row 2 before the reaper gets to it.
+ const original = rows[0]!
+ Object.defineProperty(original, 'status', {
+ get() {
+ second.errorCode = 'CANCELLED'
+ return 'failed'
+ },
+ })
+ await reaper.tick()
+ expect(calls).toEqual(['remove:1'])
+})
+
test('retries with backoff, then removes the download once retries are spent', async () => {
const rows = [
failedRow(),
diff --git a/apps/backend/src/__tests__/live-progress.test.ts b/apps/backend/src/__tests__/live-progress.test.ts
index b3e1cd1..c7be2d1 100644
--- a/apps/backend/src/__tests__/live-progress.test.ts
+++ b/apps/backend/src/__tests__/live-progress.test.ts
@@ -18,6 +18,13 @@ test('speed is averaged over the window and resets when bytes go backwards', ()
live.record(1, 100)
expect(live.get(1)).toEqual({ downloadedBytes: 100, speedBps: null })
+ // A stall keeps the byte count but drops the speed.
+ clock.now = 3500
+ live.record(1, 1100)
+ expect(live.get(1)?.speedBps).toBe(2000)
+ clock.now = 9000
+ expect(live.get(1)).toEqual({ downloadedBytes: 1100, speedBps: null })
+
live.clear(1)
expect(live.get(1)).toBeNull()
})
diff --git a/apps/backend/src/__tests__/upload-tracking.test.ts b/apps/backend/src/__tests__/upload-tracking.test.ts
index 1e8602b..1e3818f 100644
--- a/apps/backend/src/__tests__/upload-tracking.test.ts
+++ b/apps/backend/src/__tests__/upload-tracking.test.ts
@@ -11,6 +11,7 @@ import { UploadsService } from '../modules/uploads/uploads.service'
const input = {
peerName: 'alice',
+ peerKeyId: 7,
itemId: 'radarr1:movie:1',
filename: 'Movie.mkv',
totalBytes: 12,
@@ -23,22 +24,36 @@ describe('UploadsService', () => {
const repository = new UploadsRepository(openMemoryDatabase())
const service = new UploadsService(repository)
const controller = new AbortController()
- const dropped = service.start(input, true, controller.signal)
- const finished = service.start(input, true, new AbortController().signal)
+ const dropped = service.start(input, true, controller.signal)!
+ const finished = service.start(input, true, new AbortController().signal)!
controller.abort()
expect(repository.get(dropped)).toMatchObject({ status: 'aborted', error: 'peer disconnected' })
expect(repository.get(finished)?.status).toBe('uploading')
- expect(service.receipt(finished, 'alice', 12, 4000)).toBe(true)
+ expect(service.receipt(finished, 7, 12, 4000)).toBe(true)
expect(repository.get(finished)).toMatchObject({ status: 'completed', sentBytes: 12, durationMs: 4000 })
})
+ test('a peer that left before the row existed is aborted right away', () => {
+ const repository = new UploadsRepository(openMemoryDatabase())
+ const id = new UploadsService(repository).start(input, true, AbortSignal.abort())!
+ expect(repository.get(id)?.status).toBe('aborted')
+ })
+
test('a peer that sends no receipt starts unconfirmed', () => {
const repository = new UploadsRepository(openMemoryDatabase())
- const id = new UploadsService(repository).start(input, false)
+ const id = new UploadsService(repository).start(input, false)!
expect(repository.get(id)?.status).toBe('unconfirmed')
})
+
+ test('a failed insert serves the file untracked instead of throwing', () => {
+ const repository = new UploadsRepository(openMemoryDatabase())
+ repository.create = () => {
+ throw new Error('database or disk is full')
+ }
+ expect(new UploadsService(repository).start(input, true)).toBeUndefined()
+ })
})
describe('peer file route and receipt', () => {
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
index ee2ba89..4f5395e 100644
--- a/apps/backend/src/__tests__/uploads-repository.test.ts
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -8,6 +8,7 @@ function makeRepo() {
const input = {
peerName: 'alice',
+ peerKeyId: 7,
itemId: 'radarr1:movie:1',
filename: 'Movie.mkv',
totalBytes: 100,
@@ -32,12 +33,14 @@ describe('UploadsRepository', () => {
const repo = makeRepo()
const { id } = repo.create(input, 'uploading')
- expect(repo.complete(id, 'mallory', 60, 1000)).toBe(false)
- expect(repo.complete(id, 'alice', 60, 2000)).toBe(true)
+ // Another key, even one with the same name, can't close it; nor can the master key.
+ expect(repo.complete(id, 8, 60, 1000)).toBe(false)
+ expect(repo.complete(id, null, 60, 1000)).toBe(false)
+ expect(repo.complete(id, 7, 60, 2000)).toBe(true)
expect(repo.get(id)).toMatchObject({ status: 'completed', sentBytes: 60, durationMs: 2000 })
expect(repo.get(id)?.finishedAt).not.toBeNull()
// Closed rows stay closed.
- expect(repo.complete(id, 'alice', 1, 1)).toBe(false)
+ expect(repo.complete(id, 7, 1, 1)).toBe(false)
})
test('an older peer\'s row can still be completed or aborted', () => {
@@ -45,14 +48,14 @@ describe('UploadsRepository', () => {
const a = repo.create(input, 'unconfirmed')
const b = repo.create(input, 'unconfirmed')
repo.markAborted(a.id, 'peer disconnected')
- expect(repo.complete(b.id, 'alice', 60, 10)).toBe(true)
+ expect(repo.complete(b.id, 7, 60, 10)).toBe(true)
expect(repo.get(a.id)?.status).toBe('aborted')
})
test('a disconnect after completion changes nothing', () => {
const repo = makeRepo()
const { id } = repo.create(input, 'uploading')
- repo.complete(id, 'alice', 60, 10)
+ repo.complete(id, 7, 60, 10)
repo.markAborted(id, 'peer disconnected')
expect(repo.get(id)?.status).toBe('completed')
})
@@ -76,7 +79,7 @@ describe('UploadsRepository', () => {
const repo = makeRepo()
const running = repo.create(input, 'uploading')
const done = repo.create(input, 'uploading')
- repo.complete(done.id, 'alice', 60, 10)
+ repo.complete(done.id, 7, 60, 10)
expect(repo.reconcileInterrupted()).toBe(1)
expect(repo.get(running.id)).toMatchObject({ status: 'aborted', error: 'interrupted by a jack restart' })
@@ -88,7 +91,7 @@ describe('UploadsRepository', () => {
let calls = 0
repo.subscribe(() => calls++)
const { id } = repo.create(input, 'uploading')
- repo.complete(id, 'alice', 60, 10)
+ repo.complete(id, 7, 60, 10)
expect(calls).toBe(2)
})
})
diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts
index 598c4c5..d3ed165 100644
--- a/apps/backend/src/database/schema.ts
+++ b/apps/backend/src/database/schema.ts
@@ -100,7 +100,11 @@ export type UploadStatus = typeof UPLOAD_STATUSES[number]
export const uploads = sqliteTable('uploads', {
id: integer('id').primaryKey({ autoIncrement: true }),
// Name of the api key the peer authenticated with ('master' for the master key).
+ // Display only: names are optional, editable and not unique.
peerName: text('peer_name').notNull(),
+ // The api key's row id, which owns the upload: only that key's receipt can
+ // close it. Null for the master key.
+ peerKeyId: integer('peer_key_id'),
itemId: text('item_id').notNull(),
filename: text('filename').notNull(),
// Size of the whole file on disk.
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index 17072dc..963250d 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -139,11 +139,16 @@ const importWatcher = downloadsConfig
const downloadsController = new DownloadsManagementController(downloadsRepository, downloadsService, importWatcher)
const failedDownloadReaper = downloadsConfig
? new FailedDownloadReaper({
- // Read per tick, like unlinkImportedFiles, so the UI toggle applies live.
- enabled: () => configService?.getRawDownloads()?.removeFailedDownloads ?? downloadsConfig.removeFailedDownloads,
+ // Read per tick so the UI toggle applies live. With the management API, the
+ // file is the truth: the UI turns it off by dropping the key, which must read
+ // as off rather than falling back to the value jack started with.
+ enabled: () => configService
+ ? configService.getRawDownloads()?.removeFailedDownloads ?? false
+ : downloadsConfig.removeFailedDownloads,
list: () => downloadsRepository.list(),
+ get: id => downloadsRepository.get(id),
retry: id => downloadsController.retry(id),
- remove: id => downloadsController.delete(id),
+ remove: (id, onlyIf) => downloadsService!.delete(id, onlyIf),
maxRetries: downloadsConfig.failedDownloadRetries,
baseDelayMs: downloadsConfig.failedRetryBaseDelayMs,
maxDelayMs: downloadsConfig.retryMaxDelayMs,
diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts
index a0dba21..82c0cd7 100644
--- a/apps/backend/src/lib/servers/peer.ts
+++ b/apps/backend/src/lib/servers/peer.ts
@@ -257,7 +257,6 @@ export class PeerConnector extends ServerConnector {
return this.fetch(`/peer/items/${encodeURIComponent(id)}`, { method: 'GET', schema: Release })
}
- @requiresInitialization
/**
* Tell the serving jack this file arrived and how long it took. It sends files
* zero-copy, so it can see an early disconnect but not the end of a transfer.
@@ -292,6 +291,7 @@ export class PeerConnector extends ServerConnector {
})
}
+ @requiresInitialization
async downloadFile(id: string, destPath: string, options: PeerDownloadOptions = {}): Promise {
return withSpan('peer.download_file', {
'peer.name': this.name,
diff --git a/apps/backend/src/middleware/require-auth.ts b/apps/backend/src/middleware/require-auth.ts
index 8e9c1bb..aef7371 100644
--- a/apps/backend/src/middleware/require-auth.ts
+++ b/apps/backend/src/middleware/require-auth.ts
@@ -5,7 +5,11 @@ import { constantTimeEqual, hashKey, isGeneratedKey, isManagedKey } from '../lib
import { UnauthorizedError } from '../lib/errors/UnauthorizedError'
export interface AuthVariables {
+ // For display: key names are optional, editable and not unique.
apiKeyName?: string
+ // Stable identity of the peer key (its row id), for ownership checks. Unset for
+ // the master key and managed keys.
+ apiKeyId?: number
}
// Each protected surface accepts exactly one class of key, so the scope is mounted
@@ -63,6 +67,7 @@ export function requireApiKey(masterKey: string, scope: ApiKeyScope) {
}
ctx.set('apiKeyName', resolution.row.name ?? 'unnamed')
+ ctx.set('apiKeyId', resolution.row.id)
return next()
})
}
diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts
index 00e596a..ae7976d 100644
--- a/apps/backend/src/modules/downloads/downloads.service.ts
+++ b/apps/backend/src/modules/downloads/downloads.service.ts
@@ -331,7 +331,11 @@ export class DownloadsService {
return repo.get(id) ?? record
}
- async delete(id: number): Promise {
+ /**
+ * `onlyIf`, when given, is checked against the row inside the per-row lock: if
+ * it no longer holds (say, the user retried it meanwhile), nothing is deleted.
+ */
+ async delete(id: number, onlyIf?: (row: DownloadRecord) => boolean): Promise {
const repo = this.requireRepository()
const record = repo.get(id)
if (!record)
@@ -344,6 +348,8 @@ export class DownloadsService {
const current = repo.get(id)
if (!current)
throw new NotFoundError(`Download ${id} not found`)
+ if (onlyIf && !onlyIf(current))
+ return
if (current.status === 'downloading')
await this.cancel(id)
diff --git a/apps/backend/src/modules/downloads/failed-download-reaper.ts b/apps/backend/src/modules/downloads/failed-download-reaper.ts
index a39977d..46002cb 100644
--- a/apps/backend/src/modules/downloads/failed-download-reaper.ts
+++ b/apps/backend/src/modules/downloads/failed-download-reaper.ts
@@ -5,10 +5,15 @@ export interface FailedDownloadReaperOptions {
/** Read every tick, so toggling `downloads.removeFailedDownloads` applies live. */
enabled: () => boolean
list: () => DownloadRecord[]
+ /** The row as it is now; each one is re-read right before acting on it. */
+ get: (id: number) => DownloadRecord | null
/** Repeats the row's last failed operation (transfer or import). */
retry: (id: number) => Promise
- /** Deletes the row and its unshared partial or completed files. */
- remove: (id: number) => Promise
+ /**
+ * Deletes the row and its unshared partial or completed files, but only if
+ * `isReapable` still holds once the per-row delete lock is taken.
+ */
+ remove: (id: number, onlyIf: (row: DownloadRecord) => boolean) => Promise
maxRetries: number
baseDelayMs: number
maxDelayMs: number
@@ -16,6 +21,11 @@ export interface FailedDownloadReaperOptions {
now?: () => number
}
+/** A failed row the reaper may touch. A user cancel keeps its .part on purpose. */
+export function isReapable(row: DownloadRecord): boolean {
+ return row.status === 'failed' && row.errorCode !== 'CANCELLED'
+}
+
/** Same rule the UI uses to offer ***Retry***: only these failures can be repeated. */
function canRetry(row: DownloadRecord): boolean {
if (!row.operationFailed)
@@ -86,9 +96,11 @@ export class FailedDownloadReaper {
this.state.delete(id)
}
- for (const row of rows) {
- // A user cancel keeps its .part on purpose; leave it for them.
- if (row.status !== 'failed' || row.errorCode === 'CANCELLED')
+ for (const { id } of rows) {
+ // Re-read: earlier iterations await, and the user may have retried or
+ // cancelled this row meanwhile.
+ const row = this.options.get(id)
+ if (!row || !isReapable(row))
continue
const entry = this.state.get(row.id) ?? { retries: 0 }
@@ -131,7 +143,7 @@ export class FailedDownloadReaper {
private async removeRow(row: DownloadRecord, retries: number): Promise {
try {
- await this.options.remove(row.id)
+ await this.options.remove(row.id, isReapable)
this.state.delete(row.id)
logger.info({
id: row.id,
diff --git a/apps/backend/src/modules/downloads/live-progress.ts b/apps/backend/src/modules/downloads/live-progress.ts
index ef4179f..29793bb 100644
--- a/apps/backend/src/modules/downloads/live-progress.ts
+++ b/apps/backend/src/modules/downloads/live-progress.ts
@@ -5,6 +5,9 @@ const SPEED_WINDOW_MS = 10_000
// Live updates fire at most this often, so the SSE stream (a full snapshot per
// notify) stays at about one frame per second however many transfers run.
const NOTIFY_INTERVAL_MS = 1000
+// No sample for this long means the transfer stalled or is waiting to retry: the
+// byte count stays, the speed goes.
+const STALE_MS = 5000
export interface LiveProgress {
downloadedBytes: number
@@ -22,10 +25,15 @@ export class LiveProgressTracker {
private readonly samples = new Map()
private readonly subscribers = new Set<() => void>()
private notifyTimer?: ReturnType
+ // Fires once samples stop coming, so open pages drop a speed that went stale.
+ private staleTimer?: ReturnType
constructor(private readonly now: () => number = Date.now) {}
record(id: number, bytes: number): void {
+ clearTimeout(this.staleTimer)
+ this.staleTimer = setTimeout(() => this.scheduleNotify(), STALE_MS + NOTIFY_INTERVAL_MS)
+ this.staleTimer.unref?.()
const t = this.now()
const kept = (this.samples.get(id) ?? []).filter(s => t - s.t <= SPEED_WINDOW_MS)
// Fewer bytes than before means the transfer started over (restart or retry).
@@ -49,9 +57,10 @@ export class LiveProgressTracker {
const first = window[0]!
const last = window.at(-1)!
const elapsedMs = last.t - first.t
+ const stale = this.now() - last.t > STALE_MS
return {
downloadedBytes: last.bytes,
- speedBps: elapsedMs > 0
+ speedBps: elapsedMs > 0 && !stale
? ((last.bytes - first.bytes) * 1000) / elapsedMs
: null,
}
diff --git a/apps/backend/src/modules/peer/peer.router.ts b/apps/backend/src/modules/peer/peer.router.ts
index 6080e2b..1b2f212 100644
--- a/apps/backend/src/modules/peer/peer.router.ts
+++ b/apps/backend/src/modules/peer/peer.router.ts
@@ -144,6 +144,7 @@ export function getPeerRouter(controller: PeerController, uploads?: UploadsServi
const uploadId = uploads && c.req.method !== 'HEAD'
? uploads.start({
peerName: c.get('apiKeyName') ?? 'master',
+ peerKeyId: c.get('apiKeyId') ?? null,
itemId,
filename: result.filename,
totalBytes: result.type === 'partial'
@@ -202,7 +203,7 @@ export function getPeerRouter(controller: PeerController, uploads?: UploadsServi
const uploadId = Number(c.req.param('uploadId'))
const { bytes, durationMs } = c.req.valid('json')
if (uploads && Number.isSafeInteger(uploadId))
- uploads.receipt(uploadId, c.get('apiKeyName') ?? 'master', bytes, durationMs)
+ uploads.receipt(uploadId, c.get('apiKeyId') ?? null, bytes, durationMs)
return c.body(null, 204)
})
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
index 93df261..8a840b8 100644
--- a/apps/backend/src/modules/uploads/uploads.repository.ts
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -1,12 +1,13 @@
import type { AppDatabase } from '../../database/connection'
import type { NewUploadRow, UploadRow } from '../../database/schema'
-import { and, desc, eq, inArray, lt } from 'drizzle-orm'
+import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm'
import { uploads } from '../../database/schema'
export type UploadRecord = UploadRow
export interface CreateUploadInput {
peerName: string
+ peerKeyId: number | null
itemId: string
filename: string
totalBytes: number
@@ -89,11 +90,14 @@ export class UploadsRepository {
/**
* The downloading peer's receipt: it got `sentBytes` in `durationMs`. Only the
- * peer the row belongs to can close it, and only while it is still open.
- * Returns whether the row was updated.
+ * key the row belongs to (null for the master key) can close it, and only while
+ * it is still open. Returns whether the row was updated.
*/
- complete(id: number, peerName: string, sentBytes: number, durationMs: number): boolean {
+ complete(id: number, peerKeyId: number | null, sentBytes: number, durationMs: number): boolean {
const timestamp = nowIso()
+ const owner = peerKeyId === null
+ ? isNull(uploads.peerKeyId)
+ : eq(uploads.peerKeyId, peerKeyId)
const changed = this.db.update(uploads)
.set({
status: 'completed',
@@ -103,7 +107,7 @@ export class UploadsRepository {
updatedAt: timestamp,
finishedAt: timestamp,
})
- .where(and(eq(uploads.id, id), eq(uploads.peerName, peerName), inArray(uploads.status, [...OPEN])))
+ .where(and(eq(uploads.id, id), owner, inArray(uploads.status, [...OPEN])))
.returning({ id: uploads.id })
.all()
if (changed.length > 0)
diff --git a/apps/backend/src/modules/uploads/uploads.service.ts b/apps/backend/src/modules/uploads/uploads.service.ts
index 8d27caf..896d5b6 100644
--- a/apps/backend/src/modules/uploads/uploads.service.ts
+++ b/apps/backend/src/modules/uploads/uploads.service.ts
@@ -1,4 +1,5 @@
import type { CreateUploadInput, UploadRecord, UploadsRepository } from './uploads.repository'
+import { logger } from '../../logger'
// A receipt that hasn't come after this long won't: the row goes `unconfirmed`.
const RECEIPT_WAIT_MS = 48 * 60 * 60 * 1000
@@ -15,17 +16,33 @@ export class UploadsService {
/**
* Starts a row for this request and returns its id, for the `X-Jack-Upload-Id`
* response header. `expectsReceipt` is whether the peer said it will report back.
+ *
+ * Returns undefined when the row can't be written (e.g. the database disk is
+ * full): the file is still served, just untracked. Bookkeeping never fails a
+ * transfer.
*/
- start(input: CreateUploadInput, expectsReceipt: boolean, signal?: AbortSignal): number {
- const upload = this.repository.create(input, expectsReceipt ? 'uploading' : 'unconfirmed')
- // Bun aborts the request signal only when the peer disconnects before the
- // whole body went out; a finished response never fires it.
- signal?.addEventListener('abort', () => this.repository.markAborted(upload.id, 'peer disconnected'), { once: true })
- return upload.id
+ start(input: CreateUploadInput, expectsReceipt: boolean, signal?: AbortSignal): number | undefined {
+ try {
+ const upload = this.repository.create(input, expectsReceipt ? 'uploading' : 'unconfirmed')
+ const abort = () => this.repository.markAborted(upload.id, 'peer disconnected')
+ // The peer may have left while jack was looking up the file, and a listener
+ // added now would never fire.
+ if (signal?.aborted)
+ abort()
+ // Bun aborts the request signal only when the peer disconnects before the
+ // whole body went out; a finished response never fires it.
+ else
+ signal?.addEventListener('abort', abort, { once: true })
+ return upload.id
+ }
+ catch (err) {
+ logger.warn({ itemId: input.itemId, peer: input.peerName, err }, 'Could not record an upload; serving it untracked')
+ return undefined
+ }
}
- receipt(id: number, peerName: string, sentBytes: number, durationMs: number): boolean {
- return this.repository.complete(id, peerName, sentBytes, durationMs)
+ receipt(id: number, peerKeyId: number | null, sentBytes: number, durationMs: number): boolean {
+ return this.repository.complete(id, peerKeyId, sentBytes, durationMs)
}
expireUnconfirmed(now = Date.now()): number {
diff --git a/apps/ui/app/pages/downloads.vue b/apps/ui/app/pages/downloads.vue
index 4d8c14b..1cf2ef8 100644
--- a/apps/ui/app/pages/downloads.vue
+++ b/apps/ui/app/pages/downloads.vue
@@ -68,7 +68,6 @@ async function runAction(download: DownloadItem, action: DownloadAction) {
// Row selection for bulk actions, keyed by download id (see get-row-id) so it
// survives live updates and paging.
const rowSelection = ref>({})
-const selected = computed(() => (data.value?.downloads ?? []).filter(d => rowSelection.value[String(d.id)]))
const bulkPending = ref()
const BULK_ACTIONS: { action: DownloadAction, label: string, done: string, icon: string, color: 'neutral' | 'error' | 'primary' }[] = [
@@ -101,54 +100,10 @@ function pluralDownloads(count: number): string {
: `${count} downloads`
}
-// Each action only applies to the selected rows that support it.
-function eligibleFor(action: DownloadAction): DownloadItem[] {
- return selected.value.filter(d => downloadActionsFor(d).includes(action))
-}
-
function toggleSelected(download: DownloadItem, value: boolean | 'indeterminate') {
rowSelection.value = { ...rowSelection.value, [String(download.id)]: value === true }
}
-async function runBulkAction(action: DownloadAction) {
- const targets = eligibleFor(action)
- if (bulkPending.value || targets.length === 0)
- return
- const confirmation = action === 'cancel'
- ? `Cancel ${pluralDownloads(targets.length)}? Partial files will be kept so the transfers can be retried.`
- : action === 'delete'
- ? `Delete ${pluralDownloads(targets.length)}? This removes their history and any partial or completed download artifacts.`
- : null
- // eslint-disable-next-line no-alert -- destructive actions require confirmation
- if (confirmation && !window.confirm(confirmation))
- return
-
- bulkPending.value = action
- const results = await Promise.allSettled(targets.map(d => sendAction(d, action)))
- bulkPending.value = undefined
-
- const failures = results.filter(r => r.status === 'rejected')
- const done = targets.length - failures.length
- // Keep the failed rows selected so they can be retried or inspected.
- const next = { ...rowSelection.value }
- for (const [index, result] of results.entries()) {
- if (result.status === 'fulfilled')
- delete next[String(targets[index]!.id)]
- }
- rowSelection.value = next
-
- if (done > 0)
- toast.add({ title: `${BULK_ACTIONS.find(b => b.action === action)!.done} ${pluralDownloads(done)}`, color: 'success' })
- if (failures.length > 0) {
- toast.add({
- title: `Failed to ${action} ${pluralDownloads(failures.length)}`,
- description: extractError(failures[0]!.reason),
- color: 'error',
- })
- }
- await refresh()
-}
-
const statusBadge: Record = {
downloading: { color: 'primary', label: 'Downloading' },
import_queued: { color: 'warning', label: 'Import queued' },
@@ -242,6 +197,55 @@ const filtered = computed(() => {
&& (codeFilter.value.length === 0 || (d.errorCode != null && codeFilter.value.includes(d.errorCode))))
})
+// Bulk targets are only rows still in view: a live update can move a selected row
+// out of the current filter (a download finishing under "Downloading"), and a
+// bulk action must never reach a row the user can't see.
+const selected = computed(() => filtered.value.filter(d => rowSelection.value[String(d.id)]))
+
+// Each action only applies to the selected rows that support it.
+function eligibleFor(action: DownloadAction): DownloadItem[] {
+ return selected.value.filter(d => downloadActionsFor(d).includes(action))
+}
+
+async function runBulkAction(action: DownloadAction) {
+ const targets = eligibleFor(action)
+ if (bulkPending.value || targets.length === 0)
+ return
+ const confirmation = action === 'cancel'
+ ? `Cancel ${pluralDownloads(targets.length)}? Partial files will be kept so the transfers can be retried.`
+ : action === 'delete'
+ ? `Delete ${pluralDownloads(targets.length)}? This removes their history and any partial or completed download artifacts.`
+ : null
+ // eslint-disable-next-line no-alert -- destructive actions require confirmation
+ if (confirmation && !window.confirm(confirmation))
+ return
+
+ bulkPending.value = action
+ const results = await Promise.allSettled(targets.map(d => sendAction(d, action)))
+ bulkPending.value = undefined
+
+ const failures = results.filter(r => r.status === 'rejected')
+ const done = targets.length - failures.length
+ // Keep the failed rows selected so they can be retried or inspected.
+ const next = { ...rowSelection.value }
+ for (const [index, result] of results.entries()) {
+ if (result.status === 'fulfilled')
+ delete next[String(targets[index]!.id)]
+ }
+ rowSelection.value = next
+
+ if (done > 0)
+ toast.add({ title: `${BULK_ACTIONS.find(b => b.action === action)!.done} ${pluralDownloads(done)}`, color: 'success' })
+ if (failures.length > 0) {
+ toast.add({
+ title: `Failed to ${action} ${pluralDownloads(failures.length)}`,
+ description: extractError(failures[0]!.reason),
+ color: 'error',
+ })
+ }
+ await refresh()
+}
+
// Client-side pagination: just slice the filtered list. No server round-trips.
const PAGE_SIZE = 10
const page = ref(1)
diff --git a/website/guide/management-ui.md b/website/guide/management-ui.md
index 24778b6..4b7122d 100644
--- a/website/guide/management-ui.md
+++ b/website/guide/management-ui.md
@@ -84,8 +84,8 @@ one up.
One ***Save*** covers the form and only lights up once something changed;
***Revert*** discards your edits. `unlinkImportedFiles` and
-`removeFailedDownloads` take effect the moment you save. **Every other key here is read at startup, so restart jack** to apply
-it.
+`removeFailedDownloads` take effect the moment you save. **Every other key here
+is read at startup, so restart jack** to apply it.
## Settings -> Quick linking
From b81d5069d6236b0161cd61459bf40ad44ff340b9 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 00:34:55 +0100
Subject: [PATCH 10/18] fix: stop the reaper when switched off mid-pass,
contain upload tracking errors, summarize uploads in SQL
---
.../__tests__/failed-download-reaper.test.ts | 27 ++++++++++++++++
.../src/__tests__/uploads-repository.test.ts | 20 ++++++++++++
.../downloads/failed-download-reaper.ts | 9 ++++--
.../src/modules/status/status.controller.ts | 22 +++++++------
.../src/modules/uploads/uploads.repository.ts | 32 +++++++++++++++++--
.../src/modules/uploads/uploads.service.ts | 20 ++++++++++--
6 files changed, 112 insertions(+), 18 deletions(-)
diff --git a/apps/backend/src/__tests__/failed-download-reaper.test.ts b/apps/backend/src/__tests__/failed-download-reaper.test.ts
index 951d69b..9a8c169 100644
--- a/apps/backend/src/__tests__/failed-download-reaper.test.ts
+++ b/apps/backend/src/__tests__/failed-download-reaper.test.ts
@@ -43,6 +43,33 @@ function setup(rows: DownloadRecord[], options: { enabled?: boolean, retryFails?
return { reaper, clock, calls }
}
+test('stops mid-pass when removal is switched off', async () => {
+ const rows = [
+ failedRow({ id: 1, lastOperation: 'import' }),
+ failedRow({ id: 2, lastOperation: 'import' }),
+ ]
+ const switches = { on: true }
+ const calls: string[] = []
+ const reaper = new FailedDownloadReaper({
+ enabled: () => switches.on,
+ list: () => [...rows],
+ get: id => rows.find(row => row.id === id) ?? null,
+ retry: async () => {},
+ remove: async (id, onlyIf) => {
+ if (!onlyIf(rows.find(row => row.id === id)!))
+ return
+ calls.push(`remove:${id}`)
+ // The user turns removal off while this deletion runs.
+ switches.on = false
+ },
+ maxRetries: 3,
+ baseDelayMs: 100,
+ maxDelayMs: 250,
+ })
+ await reaper.tick()
+ expect(calls).toEqual(['remove:1'])
+})
+
test('re-reads each row, so one the user cancelled mid-pass is left alone', async () => {
const second = failedRow({ id: 2, lastOperation: 'import' })
const rows = [
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
index 4f5395e..0382e1a 100644
--- a/apps/backend/src/__tests__/uploads-repository.test.ts
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -68,6 +68,26 @@ describe('UploadsRepository', () => {
expect(repo.get(id)?.status).toBe('unconfirmed')
})
+ test('summarizes in SQL and lists only running uploads as active', () => {
+ const repo = makeRepo()
+ const running = repo.create(input, 'uploading')
+ const done = repo.create(input, 'uploading')
+ repo.complete(done.id, 7, 60, 10)
+ repo.create(input, 'unconfirmed')
+
+ expect(repo.summary()).toEqual({
+ byStatus: {
+ uploading: 1,
+ completed: 1,
+ aborted: 0,
+ unconfirmed: 1,
+ },
+ total: 3,
+ bytesSent: 60,
+ })
+ expect(repo.listActive().map(r => r.id)).toEqual([running.id])
+ })
+
test('lists newest first', () => {
const repo = makeRepo()
const first = repo.create(input, 'uploading')
diff --git a/apps/backend/src/modules/downloads/failed-download-reaper.ts b/apps/backend/src/modules/downloads/failed-download-reaper.ts
index 46002cb..8fe53d5 100644
--- a/apps/backend/src/modules/downloads/failed-download-reaper.ts
+++ b/apps/backend/src/modules/downloads/failed-download-reaper.ts
@@ -97,8 +97,10 @@ export class FailedDownloadReaper {
}
for (const { id } of rows) {
- // Re-read: earlier iterations await, and the user may have retried or
- // cancelled this row meanwhile.
+ // Earlier iterations await, so the user may have switched removal off, or
+ // retried or cancelled this row, meanwhile: check both again.
+ if (!this.options.enabled())
+ return
const row = this.options.get(id)
if (!row || !isReapable(row))
continue
@@ -143,7 +145,8 @@ export class FailedDownloadReaper {
private async removeRow(row: DownloadRecord, retries: number): Promise {
try {
- await this.options.remove(row.id, isReapable)
+ // Checked again inside the per-row delete lock, setting included.
+ await this.options.remove(row.id, current => this.options.enabled() && isReapable(current))
this.state.delete(row.id)
logger.info({
id: row.id,
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 4580c58..468f0ad 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -171,19 +171,21 @@ export class StatusController {
}
}
+ // Runs on every overview snapshot (about once a second while downloads move), so
+ // totals come from SQLite and only running uploads are loaded: the history
+ // grows forever.
private getUploadsSummary() {
- const records = this.uploads?.list() ?? []
- const byStatus = Object.fromEntries(UPLOAD_STATUSES.map(s => [s, 0])) as Record
- let bytesSent = 0
- for (const record of records) {
- byStatus[record.status]++
- bytesSent += record.sentBytes
+ if (!this.uploads) {
+ return {
+ total: 0,
+ byStatus: Object.fromEntries(UPLOAD_STATUSES.map(s => [s, 0])) as Record,
+ bytesSent: 0,
+ active: [],
+ }
}
return {
- total: records.length,
- byStatus,
- bytesSent,
- active: records.filter(r => r.status === 'uploading').map(this.enrichUpload),
+ ...this.uploads.repository.summary(),
+ active: this.uploads.repository.listActive().map(this.enrichUpload),
}
}
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
index 8a840b8..6af8807 100644
--- a/apps/backend/src/modules/uploads/uploads.repository.ts
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -1,7 +1,7 @@
import type { AppDatabase } from '../../database/connection'
-import type { NewUploadRow, UploadRow } from '../../database/schema'
-import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm'
-import { uploads } from '../../database/schema'
+import type { NewUploadRow, UploadRow, UploadStatus } from '../../database/schema'
+import { and, count, desc, eq, inArray, isNull, lt, sum } from 'drizzle-orm'
+import { UPLOAD_STATUSES, uploads } from '../../database/schema'
export type UploadRecord = UploadRow
@@ -66,6 +66,32 @@ export class UploadsRepository {
return this.db.select().from(uploads).where(eq(uploads.id, id)).get() ?? null
}
+ /** Totals for the dashboard, counted in SQLite so the history is never loaded. */
+ summary(): { byStatus: Record, total: number, bytesSent: number } {
+ const rows = this.db.select({
+ status: uploads.status,
+ count: count(),
+ bytes: sum(uploads.sentBytes),
+ })
+ .from(uploads)
+ .groupBy(uploads.status)
+ .all()
+ const byStatus = Object.fromEntries(UPLOAD_STATUSES.map(s => [s, 0])) as Record
+ let total = 0
+ let bytesSent = 0
+ for (const row of rows) {
+ byStatus[row.status] = row.count
+ total += row.count
+ bytesSent += Number(row.bytes ?? 0)
+ }
+ return { byStatus, total, bytesSent }
+ }
+
+ /** Uploads still being sent, newest first. */
+ listActive(): UploadRecord[] {
+ return this.db.select().from(uploads).where(eq(uploads.status, 'uploading')).orderBy(desc(uploads.startedAt), desc(uploads.id)).all()
+ }
+
/** Newest first; the id breaks ties between rows started in the same millisecond. */
list(): UploadRecord[] {
return this.db.select().from(uploads).orderBy(desc(uploads.startedAt), desc(uploads.id)).all()
diff --git a/apps/backend/src/modules/uploads/uploads.service.ts b/apps/backend/src/modules/uploads/uploads.service.ts
index 896d5b6..7a11bb1 100644
--- a/apps/backend/src/modules/uploads/uploads.service.ts
+++ b/apps/backend/src/modules/uploads/uploads.service.ts
@@ -24,7 +24,16 @@ export class UploadsService {
start(input: CreateUploadInput, expectsReceipt: boolean, signal?: AbortSignal): number | undefined {
try {
const upload = this.repository.create(input, expectsReceipt ? 'uploading' : 'unconfirmed')
- const abort = () => this.repository.markAborted(upload.id, 'peer disconnected')
+ // Runs from an event listener, outside this try: a database error there must
+ // be logged, not thrown into the server.
+ const abort = () => {
+ try {
+ this.repository.markAborted(upload.id, 'peer disconnected')
+ }
+ catch (err) {
+ logger.warn({ uploadId: upload.id, err }, 'Could not mark an upload aborted')
+ }
+ }
// The peer may have left while jack was looking up the file, and a listener
// added now would never fire.
if (signal?.aborted)
@@ -45,8 +54,15 @@ export class UploadsService {
return this.repository.complete(id, peerKeyId, sentBytes, durationMs)
}
+ /** Called from a timer, so it never throws: a failure is logged and retried next time. */
expireUnconfirmed(now = Date.now()): number {
- return this.repository.expireUnconfirmed(new Date(now - RECEIPT_WAIT_MS).toISOString())
+ try {
+ return this.repository.expireUnconfirmed(new Date(now - RECEIPT_WAIT_MS).toISOString())
+ }
+ catch (err) {
+ logger.warn({ err }, 'Could not expire uploads waiting for a receipt')
+ return 0
+ }
}
list(): UploadRecord[] {
From 75ef3207e13f8adfeab2a8a76d1d5a4f77fda5ab Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 00:50:07 +0100
Subject: [PATCH 11/18] fix: coalesce every change source into one snapshot per
second per stream
---
.../src/__tests__/stream-coalesce.test.ts | 39 +++++++++++
.../src/modules/status/status.controller.ts | 69 ++++++++++++++-----
2 files changed, 89 insertions(+), 19 deletions(-)
create mode 100644 apps/backend/src/__tests__/stream-coalesce.test.ts
diff --git a/apps/backend/src/__tests__/stream-coalesce.test.ts b/apps/backend/src/__tests__/stream-coalesce.test.ts
new file mode 100644
index 0000000..6780ebf
--- /dev/null
+++ b/apps/backend/src/__tests__/stream-coalesce.test.ts
@@ -0,0 +1,39 @@
+import { expect, test } from 'bun:test'
+import { coalesce } from '../modules/status/status.controller'
+
+function source() {
+ const subscribers = new Set<() => void>()
+ return {
+ emit: () => {
+ for (const s of subscribers) s()
+ },
+ subscribe: (s: () => void) => {
+ subscribers.add(s)
+ return () => subscribers.delete(s)
+ },
+ }
+}
+
+test('folds bursts from every source into one snapshot per interval', async () => {
+ const a = source()
+ const b = source()
+ const fired: number[] = []
+ const unsubscribe = coalesce(() => fired.push(Date.now()), [a.subscribe, undefined, b.subscribe], 50)
+
+ a.emit()
+ expect(fired).toHaveLength(1)
+ // Within the interval: folded into one trailing snapshot.
+ b.emit()
+ a.emit()
+ b.emit()
+ expect(fired).toHaveLength(1)
+ await Bun.sleep(80)
+ expect(fired).toHaveLength(2)
+
+ // A pending snapshot is dropped on unsubscribe.
+ a.emit()
+ unsubscribe()
+ await Bun.sleep(80)
+ a.emit()
+ expect(fired.length).toBeLessThanOrEqual(3)
+})
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 468f0ad..0c253a6 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -70,6 +70,43 @@ export function enrichUpload(u: UploadRecord) {
return { ...u, progress, speedBps }
}
+type ChangeSource = ((subscriber: () => void) => () => void) | undefined
+
+// Each stream sends a full snapshot per notification, so all of its sources are
+// folded together: the first change goes out at once, and any further changes
+// within the interval become a single trailing snapshot.
+export const STREAM_INTERVAL_MS = 1000
+
+export function coalesce(subscriber: () => void, sources: ChangeSource[], intervalMs = STREAM_INTERVAL_MS): () => void {
+ let lastFired = 0
+ let timer: ReturnType | undefined
+ const fire = () => {
+ timer = undefined
+ lastFired = Date.now()
+ subscriber()
+ }
+ const notify = () => {
+ if (timer)
+ return
+ const wait = lastFired + intervalMs - Date.now()
+ if (wait <= 0) {
+ fire()
+ return
+ }
+ timer = setTimeout(fire, wait)
+ }
+ const unsubscribers = sources.flatMap(source => source
+ ? [
+ source(notify),
+ ]
+ : [])
+ return () => {
+ clearTimeout(timer)
+ for (const unsubscribe of unsubscribers)
+ unsubscribe()
+ }
+}
+
export class StatusController {
constructor(
private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[], subscribe?: (subscriber: () => void) => () => void },
@@ -86,31 +123,25 @@ export class StatusController {
private enrich = (d: DownloadRecord) => enrichDownload(d, this.live)
subscribeOverview(subscriber: () => void): () => void {
- const unsubscribers = [
- this.connectors.subscribe?.(subscriber),
- this.downloads?.subscribe(subscriber),
- this.live?.subscribe(subscriber),
- this.uploads?.subscribe(subscriber),
- ].filter((unsubscribe): unsubscribe is () => void => Boolean(unsubscribe))
- return () => {
- for (const unsubscribe of unsubscribers)
- unsubscribe()
- }
+ return coalesce(subscriber, [
+ this.connectors.subscribe,
+ this.downloads && (s => this.downloads!.subscribe(s)),
+ this.live && (s => this.live!.subscribe(s)),
+ this.uploads && (s => this.uploads!.subscribe(s)),
+ ])
}
subscribeUploads(subscriber: () => void): () => void {
- return this.uploads?.subscribe(subscriber) ?? (() => {})
+ return coalesce(subscriber, [
+ this.uploads && (s => this.uploads!.subscribe(s)),
+ ])
}
subscribeDownloads(subscriber: () => void): () => void {
- const unsubscribers = [
- this.downloads?.subscribe(subscriber),
- this.live?.subscribe(subscriber),
- ].filter((unsubscribe): unsubscribe is () => void => Boolean(unsubscribe))
- return () => {
- for (const unsubscribe of unsubscribers)
- unsubscribe()
- }
+ return coalesce(subscriber, [
+ this.downloads && (s => this.downloads!.subscribe(s)),
+ this.live && (s => this.live!.subscribe(s)),
+ ])
}
/** Whether download-backed endpoints are available (a repository was injected). */
From 0f0f954c3a0e92dfc3bdaf09eb0a3a730df12e57 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 00:53:26 +0100
Subject: [PATCH 12/18] fix: subscribe the overview stream to connectors
through their object
---
.../src/__tests__/stream-coalesce.test.ts | 25 ++++++++++++++++++-
.../src/modules/status/status.controller.ts | 3 ++-
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/apps/backend/src/__tests__/stream-coalesce.test.ts b/apps/backend/src/__tests__/stream-coalesce.test.ts
index 6780ebf..f185cbc 100644
--- a/apps/backend/src/__tests__/stream-coalesce.test.ts
+++ b/apps/backend/src/__tests__/stream-coalesce.test.ts
@@ -1,5 +1,28 @@
import { expect, test } from 'bun:test'
-import { coalesce } from '../modules/status/status.controller'
+import { coalesce, StatusController } from '../modules/status/status.controller'
+
+test('the overview stream subscribes to connectors through their object', () => {
+ // Like ConnectorManager: subscribe() relies on `this`.
+ class Connectors {
+ servers = []
+ peers = []
+ private readonly subscribers = new Set<() => void>()
+ subscribe(subscriber: () => void) {
+ this.subscribers.add(subscriber)
+ return () => this.subscribers.delete(subscriber)
+ }
+
+ emit() {
+ for (const s of this.subscribers) s()
+ }
+ }
+ const connectors = new Connectors()
+ const fired: number[] = []
+ const unsubscribe = new StatusController(connectors).subscribeOverview(() => fired.push(1))
+ connectors.emit()
+ expect(fired).toHaveLength(1)
+ unsubscribe()
+})
function source() {
const subscribers = new Set<() => void>()
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 0c253a6..0d842b7 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -124,7 +124,8 @@ export class StatusController {
subscribeOverview(subscriber: () => void): () => void {
return coalesce(subscriber, [
- this.connectors.subscribe,
+ // Called through the object: ConnectorManager.subscribe reads `this`.
+ this.connectors.subscribe && (s => this.connectors.subscribe!(s)),
this.downloads && (s => this.downloads!.subscribe(s)),
this.live && (s => this.live!.subscribe(s)),
this.uploads && (s => this.uploads!.subscribe(s)),
From 1f98365c3f59ee3453122114d2a11db50f98cf79 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 01:09:14 +0100
Subject: [PATCH 13/18] fix: build the overview from SQL totals and cap
receipts at the requested bytes
---
.../__tests__/downloads-repository.test.ts | 22 +++++++++
.../src/__tests__/uploads-repository.test.ts | 2 +
.../modules/downloads/downloads.repository.ts | 47 +++++++++++++++++--
.../src/modules/status/status.controller.ts | 36 ++++++--------
.../src/modules/uploads/uploads.repository.ts | 9 ++--
5 files changed, 85 insertions(+), 31 deletions(-)
diff --git a/apps/backend/src/__tests__/downloads-repository.test.ts b/apps/backend/src/__tests__/downloads-repository.test.ts
index cc7afe5..1045b6d 100644
--- a/apps/backend/src/__tests__/downloads-repository.test.ts
+++ b/apps/backend/src/__tests__/downloads-repository.test.ts
@@ -39,6 +39,28 @@ function seed(repo: DownloadsRepository, peerId: string, filename: string) {
})
}
+describe('DownloadsRepository.summary', () => {
+ test('counts by status and sums bytes in SQL', () => {
+ const repo = makeRepo()
+ const a = seed(repo, 'p1', 'A.mkv')
+ seed(repo, 'p1', 'B.mkv')
+ repo.updateProgress(a.id, 40)
+ repo.markFailed(a.id, 'PEER_UNREACHABLE', 'nope')
+ expect(repo.summary()).toMatchObject({
+ byStatus: {
+ downloading: 1,
+ failed: 1,
+ import_queued: 0,
+ imported: 0,
+ },
+ total: 2,
+ bytesMoved: 40,
+ mismatched: 0,
+ })
+ expect(repo.listByStatus('failed', 1).map(r => r.id)).toEqual([a.id])
+ })
+})
+
describe('DownloadsRepository.reassignPeerId', () => {
test('moves only the matching rows', () => {
const repo = makeRepo()
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
index 0382e1a..c55e5e4 100644
--- a/apps/backend/src/__tests__/uploads-repository.test.ts
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -36,6 +36,8 @@ describe('UploadsRepository', () => {
// Another key, even one with the same name, can't close it; nor can the master key.
expect(repo.complete(id, 8, 60, 1000)).toBe(false)
expect(repo.complete(id, null, 60, 1000)).toBe(false)
+ // More bytes than the request asked for (60) is rejected.
+ expect(repo.complete(id, 7, 61, 1000)).toBe(false)
expect(repo.complete(id, 7, 60, 2000)).toBe(true)
expect(repo.get(id)).toMatchObject({ status: 'completed', sentBytes: 60, durationMs: 2000 })
expect(repo.get(id)?.finishedAt).not.toBeNull()
diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts
index 284728b..ffedb5e 100644
--- a/apps/backend/src/modules/downloads/downloads.repository.ts
+++ b/apps/backend/src/modules/downloads/downloads.repository.ts
@@ -4,8 +4,8 @@ import type { FileSystemErrorDetails } from '../../lib/errors/FileSystemError'
import type { Release } from '../../lib/release'
import type { ManualImportTarget } from '../../lib/servers/arr/base'
import type { DownloadErrorCode } from './download-error-codes'
-import { desc, eq, sql } from 'drizzle-orm'
-import { downloads } from '../../database/schema'
+import { count, desc, eq, sql, sum } from 'drizzle-orm'
+import { DOWNLOAD_STATUSES, downloads } from '../../database/schema'
export interface DownloadRecord {
id: number
@@ -160,6 +160,40 @@ export class DownloadsRepository {
: null
}
+ /**
+ * Overview totals, counted in SQLite so the history (and its release JSON) is
+ * never loaded. `bytesMoved` counts partial transfers too; `mismatched` is the
+ * in-flight or queued rows flagged with a size mismatch.
+ */
+ summary(): { byStatus: Record, total: number, bytesMoved: number, mismatched: number } {
+ const rows = this.db.select({
+ status: downloads.status,
+ count: count(),
+ bytes: sum(downloads.downloadedBytes),
+ mismatched: sql`sum(case when ${downloads.expectedBytesMismatch} then 1 else 0 end)`,
+ })
+ .from(downloads)
+ .groupBy(downloads.status)
+ .all()
+ const byStatus = Object.fromEntries(DOWNLOAD_STATUSES.map(s => [s, 0])) as Record
+ let total = 0
+ let bytesMoved = 0
+ let mismatched = 0
+ for (const row of rows) {
+ byStatus[row.status] = row.count
+ total += row.count
+ bytesMoved += Number(row.bytes ?? 0)
+ if (row.status === 'downloading' || row.status === 'import_queued')
+ mismatched += Number(row.mismatched ?? 0)
+ }
+ return {
+ byStatus,
+ total,
+ bytesMoved,
+ mismatched,
+ }
+ }
+
list(): DownloadRecord[] {
// Secondary sort by id breaks ties when two rows share an updatedAt
// (ISO timestamps can collide within the same millisecond).
@@ -345,9 +379,12 @@ export class DownloadsRepository {
this.notifyChanged()
}
- /** Rows in a given status (uses the status index). */
- listByStatus(status: DownloadStatus): DownloadRecord[] {
- return this.db.select().from(downloads).where(eq(downloads.status, status)).all().map(toRecord)
+ /** Rows in a given status (uses the status index), most recently updated first, optionally capped. */
+ listByStatus(status: DownloadStatus, limit?: number): DownloadRecord[] {
+ const query = this.db.select().from(downloads).where(eq(downloads.status, status)).orderBy(desc(downloads.updatedAt), desc(downloads.id))
+ return (limit === undefined
+ ? query.all()
+ : query.limit(limit).all()).map(toRecord)
}
/** Stale `downloading` rows from a prior run, returned for active re-drive (no mutation). */
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 0d842b7..afa97f9 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -153,21 +153,16 @@ export class StatusController {
getOverview() {
const peers = this.connectors.peers
const servers = this.connectors.servers
- const records = this.downloads?.list() ?? []
-
- const byStatus = Object.fromEntries(DOWNLOAD_STATUSES.map(s => [s, 0])) as Record
- // `bytesMoved` is the lifetime total of bytes actually pulled over the wire
- // (partial transfers count what they got), powering the dashboard's vanity stat.
- let bytesMoved = 0
- // True count of in-flight/queued transfers flagged with a size mismatch, so the
- // dashboard reports an accurate total even though the detail arrays are capped.
- let mismatched = 0
- for (const record of records) {
- byStatus[record.status]++
- bytesMoved += record.downloadedBytes
- if (record.expectedBytesMismatch && (record.status === 'downloading' || record.status === 'import_queued'))
- mismatched++
+ // This runs on every overview snapshot (about once a second while downloads
+ // move), so totals come from SQLite and only the rows shown are loaded. The
+ // detail lists are capped; `mismatched` still counts every flagged row.
+ const totals = this.downloads?.summary() ?? {
+ byStatus: Object.fromEntries(DOWNLOAD_STATUSES.map(s => [s, 0])) as Record,
+ total: 0,
+ bytesMoved: 0,
+ mismatched: 0,
}
+ const inStatus = (status: DownloadStatus, limit?: number) => (this.downloads?.listByStatus(status, limit) ?? []).map(this.enrich)
return {
peers: {
@@ -190,14 +185,11 @@ export class StatusController {
})),
},
downloads: {
- total: records.length,
- byStatus,
- bytesMoved,
- mismatched,
- // `records` is sorted by updatedAt desc, so each slice is the most recent.
- active: records.filter(r => r.status === 'downloading').map(this.enrich),
- importQueued: records.filter(r => r.status === 'import_queued').slice(0, ATTENTION_LIMIT).map(this.enrich),
- failed: records.filter(r => r.status === 'failed').slice(0, ATTENTION_LIMIT).map(this.enrich),
+ ...totals,
+ // Most recently updated first.
+ active: inStatus('downloading'),
+ importQueued: inStatus('import_queued', ATTENTION_LIMIT),
+ failed: inStatus('failed', ATTENTION_LIMIT),
},
uploads: this.getUploadsSummary(),
}
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
index 6af8807..1a369d5 100644
--- a/apps/backend/src/modules/uploads/uploads.repository.ts
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -1,6 +1,6 @@
import type { AppDatabase } from '../../database/connection'
import type { NewUploadRow, UploadRow, UploadStatus } from '../../database/schema'
-import { and, count, desc, eq, inArray, isNull, lt, sum } from 'drizzle-orm'
+import { and, count, desc, eq, gte, inArray, isNull, lt, sum } from 'drizzle-orm'
import { UPLOAD_STATUSES, uploads } from '../../database/schema'
export type UploadRecord = UploadRow
@@ -116,8 +116,9 @@ export class UploadsRepository {
/**
* The downloading peer's receipt: it got `sentBytes` in `durationMs`. Only the
- * key the row belongs to (null for the master key) can close it, and only while
- * it is still open. Returns whether the row was updated.
+ * key the row belongs to (null for the master key) can close it, only while it
+ * is still open, and only with no more bytes than the request asked for, so a
+ * peer can't inflate the totals. Returns whether the row was updated.
*/
complete(id: number, peerKeyId: number | null, sentBytes: number, durationMs: number): boolean {
const timestamp = nowIso()
@@ -133,7 +134,7 @@ export class UploadsRepository {
updatedAt: timestamp,
finishedAt: timestamp,
})
- .where(and(eq(uploads.id, id), owner, inArray(uploads.status, [...OPEN])))
+ .where(and(eq(uploads.id, id), owner, inArray(uploads.status, [...OPEN]), gte(uploads.requestedBytes, sentBytes)))
.returning({ id: uploads.id })
.all()
if (changed.length > 0)
From 3d5f67687f10dc0e7419d5b9100fdd9fee78dcfa Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 01:12:30 +0100
Subject: [PATCH 14/18] fix: accept an upload receipt only for exactly the
requested bytes
---
apps/backend/src/__tests__/uploads-repository.test.ts | 3 ++-
apps/backend/src/modules/uploads/uploads.repository.ts | 9 +++++----
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
index c55e5e4..4d10c55 100644
--- a/apps/backend/src/__tests__/uploads-repository.test.ts
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -36,8 +36,9 @@ describe('UploadsRepository', () => {
// Another key, even one with the same name, can't close it; nor can the master key.
expect(repo.complete(id, 8, 60, 1000)).toBe(false)
expect(repo.complete(id, null, 60, 1000)).toBe(false)
- // More bytes than the request asked for (60) is rejected.
+ // Anything but the requested 60 bytes is rejected.
expect(repo.complete(id, 7, 61, 1000)).toBe(false)
+ expect(repo.complete(id, 7, 59, 1000)).toBe(false)
expect(repo.complete(id, 7, 60, 2000)).toBe(true)
expect(repo.get(id)).toMatchObject({ status: 'completed', sentBytes: 60, durationMs: 2000 })
expect(repo.get(id)?.finishedAt).not.toBeNull()
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
index 1a369d5..02de750 100644
--- a/apps/backend/src/modules/uploads/uploads.repository.ts
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -1,6 +1,6 @@
import type { AppDatabase } from '../../database/connection'
import type { NewUploadRow, UploadRow, UploadStatus } from '../../database/schema'
-import { and, count, desc, eq, gte, inArray, isNull, lt, sum } from 'drizzle-orm'
+import { and, count, desc, eq, inArray, isNull, lt, sum } from 'drizzle-orm'
import { UPLOAD_STATUSES, uploads } from '../../database/schema'
export type UploadRecord = UploadRow
@@ -117,8 +117,9 @@ export class UploadsRepository {
/**
* The downloading peer's receipt: it got `sentBytes` in `durationMs`. Only the
* key the row belongs to (null for the master key) can close it, only while it
- * is still open, and only with no more bytes than the request asked for, so a
- * peer can't inflate the totals. Returns whether the row was updated.
+ * is still open, and only with exactly the bytes the request asked for: a
+ * receipt means the whole response arrived, and anything else would let a peer
+ * skew the totals. Returns whether the row was updated.
*/
complete(id: number, peerKeyId: number | null, sentBytes: number, durationMs: number): boolean {
const timestamp = nowIso()
@@ -134,7 +135,7 @@ export class UploadsRepository {
updatedAt: timestamp,
finishedAt: timestamp,
})
- .where(and(eq(uploads.id, id), owner, inArray(uploads.status, [...OPEN]), gte(uploads.requestedBytes, sentBytes)))
+ .where(and(eq(uploads.id, id), owner, inArray(uploads.status, [...OPEN]), eq(uploads.requestedBytes, sentBytes)))
.returning({ id: uploads.id })
.all()
if (changed.length > 0)
From 785a9ff6480f38bd9cd97384d6bd0257ff56858e Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Tue, 10 Nov 2026 00:52:19 +0100
Subject: [PATCH 15/18] refactor: track uploads as idempotent peer transfers
and derive download speed from progress samples
---
apps/backend/drizzle/0014_uploads.sql | 13 +-
apps/backend/drizzle/meta/0014_snapshot.json | 56 +++--
apps/backend/drizzle/meta/_journal.json | 2 +-
.../src/__tests__/live-progress.test.ts | 55 -----
.../src/__tests__/peer-download.test.ts | 76 +++++--
.../src/__tests__/qbittorrent-mapper.test.ts | 1 +
.../src/__tests__/upload-tracking.test.ts | 180 ++++++++-------
.../src/__tests__/uploads-repository.test.ts | 128 ++++++-----
apps/backend/src/database/schema.ts | 37 ++--
apps/backend/src/index.ts | 4 -
apps/backend/src/lib/servers/peer.ts | 106 ++++++---
apps/backend/src/lib/version.ts | 6 +-
.../modules/downloads/downloads.repository.ts | 9 +-
.../modules/downloads/downloads.service.ts | 26 ++-
.../src/modules/downloads/live-progress.ts | 101 ---------
.../src/modules/peer/peer.controller.ts | 5 +-
apps/backend/src/modules/peer/peer.router.ts | 207 +++++++++++-------
.../src/modules/status/status.controller.ts | 35 +--
.../src/modules/status/status.router.ts | 2 +-
.../src/modules/uploads/uploads.repository.ts | 129 ++++++-----
.../src/modules/uploads/uploads.service.ts | 52 +----
apps/ui/app/pages/index.vue | 4 +-
apps/ui/app/pages/uploads.vue | 13 +-
apps/ui/app/types/management.ts | 24 +-
website/guide/management-ui.md | 34 +--
25 files changed, 632 insertions(+), 673 deletions(-)
delete mode 100644 apps/backend/src/__tests__/live-progress.test.ts
delete mode 100644 apps/backend/src/modules/downloads/live-progress.ts
diff --git a/apps/backend/drizzle/0014_uploads.sql b/apps/backend/drizzle/0014_uploads.sql
index 7887b20..453b3a0 100644
--- a/apps/backend/drizzle/0014_uploads.sql
+++ b/apps/backend/drizzle/0014_uploads.sql
@@ -1,21 +1,24 @@
CREATE TABLE `uploads` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `transfer_id` text NOT NULL,
+ `idempotency_key` text NOT NULL,
`peer_name` text NOT NULL,
`peer_key_id` integer,
`item_id` text NOT NULL,
`filename` text NOT NULL,
`total_bytes` integer NOT NULL,
- `range_start` integer DEFAULT 0 NOT NULL,
- `requested_bytes` integer NOT NULL,
+ `range_start` integer,
`sent_bytes` integer DEFAULT 0 NOT NULL,
`duration_ms` integer,
`status` text NOT NULL,
- `error` text,
`started_at` text NOT NULL,
`updated_at` text NOT NULL,
`finished_at` text,
- CONSTRAINT "uploads_status_check" CHECK("uploads"."status" in ('uploading', 'completed', 'aborted', 'unconfirmed'))
+ CONSTRAINT "uploads_status_check" CHECK("uploads"."status" in ('uploading', 'completed', 'unconfirmed'))
);
--> statement-breakpoint
+CREATE UNIQUE INDEX `uploads_transfer_id_unique` ON `uploads` (`transfer_id`);--> statement-breakpoint
+CREATE UNIQUE INDEX `uploads_idempotency_key_unique` ON `uploads` (`idempotency_key`);--> statement-breakpoint
CREATE INDEX `uploads_status_idx` ON `uploads` (`status`);--> statement-breakpoint
-CREATE INDEX `uploads_started_at_idx` ON `uploads` (`started_at`);
\ No newline at end of file
+CREATE INDEX `uploads_started_at_idx` ON `uploads` (`started_at`);--> statement-breakpoint
+ALTER TABLE `downloads` ADD `speed_bps` integer;
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/0014_snapshot.json b/apps/backend/drizzle/meta/0014_snapshot.json
index 5ca2f7c..776e2b1 100644
--- a/apps/backend/drizzle/meta/0014_snapshot.json
+++ b/apps/backend/drizzle/meta/0014_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "c9f970ae-d2a3-41bd-b206-35a480a193bf",
+ "id": "f1dbe784-1ac2-4f13-84a0-08e7a930ded3",
"prevId": "7a0a25df-a2e9-4f9a-b477-42d28d9a01fc",
"tables": {
"api_keys": {
@@ -174,6 +174,13 @@
"autoincrement": false,
"default": 0
},
+ "speed_bps": {
+ "name": "speed_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"attempts": {
"name": "attempts",
"type": "integer",
@@ -920,6 +927,20 @@
"notNull": true,
"autoincrement": true
},
+ "transfer_id": {
+ "name": "transfer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
"peer_name": {
"name": "peer_name",
"type": "text",
@@ -959,15 +980,7 @@
"name": "range_start",
"type": "integer",
"primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "requested_bytes": {
- "name": "requested_bytes",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
+ "notNull": false,
"autoincrement": false
},
"sent_bytes": {
@@ -992,13 +1005,6 @@
"notNull": true,
"autoincrement": false
},
- "error": {
- "name": "error",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
"started_at": {
"name": "started_at",
"type": "text",
@@ -1022,6 +1028,20 @@
}
},
"indexes": {
+ "uploads_transfer_id_unique": {
+ "name": "uploads_transfer_id_unique",
+ "columns": [
+ "transfer_id"
+ ],
+ "isUnique": true
+ },
+ "uploads_idempotency_key_unique": {
+ "name": "uploads_idempotency_key_unique",
+ "columns": [
+ "idempotency_key"
+ ],
+ "isUnique": true
+ },
"uploads_status_idx": {
"name": "uploads_status_idx",
"columns": [
@@ -1043,7 +1063,7 @@
"checkConstraints": {
"uploads_status_check": {
"name": "uploads_status_check",
- "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'aborted', 'unconfirmed')"
+ "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'unconfirmed')"
}
}
}
diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json
index b475d6d..6491a44 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -103,7 +103,7 @@
{
"idx": 14,
"version": "6",
- "when": 1794180331966,
+ "when": 1794267794073,
"tag": "0014_uploads",
"breakpoints": true
}
diff --git a/apps/backend/src/__tests__/live-progress.test.ts b/apps/backend/src/__tests__/live-progress.test.ts
deleted file mode 100644
index c7be2d1..0000000
--- a/apps/backend/src/__tests__/live-progress.test.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { expect, test } from 'bun:test'
-import { LiveProgressTracker } from '../modules/downloads/live-progress'
-import { enrichDownload } from '../modules/status/status.controller'
-
-test('speed is averaged over the window and resets when bytes go backwards', () => {
- const clock = { now: 0 }
- const live = new LiveProgressTracker(() => clock.now)
-
- live.record(1, 0)
- expect(live.get(1)).toEqual({ downloadedBytes: 0, speedBps: null })
-
- clock.now = 2000
- live.record(1, 4000)
- expect(live.get(1)).toEqual({ downloadedBytes: 4000, speedBps: 2000 })
-
- // A restart drops the window instead of reporting a negative speed.
- clock.now = 3000
- live.record(1, 100)
- expect(live.get(1)).toEqual({ downloadedBytes: 100, speedBps: null })
-
- // A stall keeps the byte count but drops the speed.
- clock.now = 3500
- live.record(1, 1100)
- expect(live.get(1)?.speedBps).toBe(2000)
- clock.now = 9000
- expect(live.get(1)).toEqual({ downloadedBytes: 1100, speedBps: null })
-
- live.clear(1)
- expect(live.get(1)).toBeNull()
-})
-
-test('enrichDownload overlays live bytes, speed and ETA on a running transfer', () => {
- const clock = { now: 0 }
- const live = new LiveProgressTracker(() => clock.now)
- live.record(7, 1000)
- clock.now = 1000
- live.record(7, 3000)
-
- const row = {
- id: 7,
- status: 'downloading',
- downloadedBytes: 500,
- expectedBytes: 10_000,
- releaseSize: 10_000,
- } as Parameters[0]
- expect(enrichDownload(row, live)).toMatchObject({
- downloadedBytes: 3000,
- progress: 0.3,
- speedBps: 2000,
- etaSeconds: 4,
- })
-
- // Finished rows ignore whatever the tracker still holds.
- expect(enrichDownload({ ...row, status: 'import_queued' }, live)).toMatchObject({ downloadedBytes: 500, speedBps: null, etaSeconds: null })
-})
diff --git a/apps/backend/src/__tests__/peer-download.test.ts b/apps/backend/src/__tests__/peer-download.test.ts
index eaee098..ec0b5fe 100644
--- a/apps/backend/src/__tests__/peer-download.test.ts
+++ b/apps/backend/src/__tests__/peer-download.test.ts
@@ -151,39 +151,77 @@ describe('PeerConnector.downloadFile', () => {
}
})
- test('asks for a receipt and posts one after the file arrives', async () => {
- const receipts: unknown[] = []
- const seen = { receiptHeader: null as string | null }
+ test('opens a transfer on a 0.3.0 peer, streams it, and posts the receipt', async () => {
+ const seen: unknown[] = []
server.use(
- http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, ({ request }) => {
- seen.receiptHeader = request.headers.get('X-Jack-Receipt')
+ http.post(`${PEER_JACK_URL}/peer/items/:itemId/downloads`, async ({ request, params }) => {
+ seen.push({ create: params.itemId, body: await request.json() })
+ return HttpResponse.json({
+ downloadId: 'abc',
+ filename: 'Movie.mkv',
+ totalBytes: 4,
+ url: '/peer/downloads/abc/file',
+ }, { status: 201 })
+ }),
+ http.get(`${PEER_JACK_URL}/peer/downloads/:downloadId/file`, ({ params }) => {
+ seen.push({ stream: params.downloadId })
return new Response(streamOf([1, 2, 3, 4]), {
- headers: { 'Content-Length': '4', 'X-Jack-Upload-Id': '42' },
+ headers: { 'Content-Length': '4' },
})
}),
- http.post(`${PEER_JACK_URL}/peer/uploads/:uploadId/receipt`, async ({ request, params }) => {
- receipts.push({
- uploadId: params.uploadId,
+ http.post(`${PEER_JACK_URL}/peer/downloads/:downloadId/receipt`, ({ request, params }) => {
+ seen.push({
+ receipt: params.downloadId,
apiKey: request.headers.get('X-Api-Key'),
- body: await request.json(),
})
return new HttpResponse(null, { status: 204 })
}),
)
const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ ;(peer as any)._peerVersion = '0.3.0'
const dir = await mkdtemp(join(tmpdir(), 'jack-peer-receipt-'))
try {
- await peer.downloadFile('remote1:movie:99', join(dir, 'Movie.mkv'), { releaseSize: 4 })
+ await peer.downloadFile('remote1:movie:99', join(dir, 'Movie.mkv'), { releaseSize: 4, transferId: 'download-1-0' })
// Fire and forget: the download resolves without waiting for it.
- await waitFor(async () => receipts.length === 1)
- expect(seen.receiptHeader).toBe('1')
- expect(receipts[0]).toMatchObject({
- uploadId: '42',
- apiKey: 'peer-api-key',
- body: { bytes: 4 },
- })
- expect((receipts[0] as { body: { durationMs: number } }).body.durationMs).toBeGreaterThanOrEqual(0)
+ await waitFor(async () => seen.length === 3)
+ expect(seen).toEqual([
+ {
+ create: 'remote1:movie:99',
+ body: { clientTransferId: 'download-1-0' },
+ },
+ { stream: 'abc' },
+ { receipt: 'abc', apiKey: 'peer-api-key' },
+ ])
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ test('uses the untracked item route for an older peer or a failed create', async () => {
+ const creates: unknown[] = []
+ server.use(
+ http.post(`${PEER_JACK_URL}/peer/items/:itemId/downloads`, () => {
+ creates.push(1)
+ return HttpResponse.json({ error: 'database or disk is full' }, { status: 500 })
+ }),
+ http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => new Response(streamOf([1, 2, 3, 4]), {
+ headers: { 'Content-Length': '4' },
+ })),
+ )
+
+ const dir = await mkdtemp(join(tmpdir(), 'jack-peer-legacy-'))
+ try {
+ for (const version of ['0.2.0', '0.3.0']) {
+ const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ ;(peer as any)._peerVersion = version
+ const destPath = join(dir, `Movie-${version}.mkv`)
+ await peer.downloadFile('remote1:movie:99', destPath, { releaseSize: 4, transferId: 'download-1-0' })
+ expect(new Uint8Array(await Bun.file(destPath).arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
+ }
+ // Only the 0.3.0 peer was asked for a transfer.
+ expect(creates).toHaveLength(1)
}
finally {
await rm(dir, { recursive: true, force: true })
diff --git a/apps/backend/src/__tests__/qbittorrent-mapper.test.ts b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
index 7a2dccd..b8bd474 100644
--- a/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
+++ b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
@@ -24,6 +24,7 @@ function baseRecord(overrides: Partial = {}): DownloadRecord {
expectedBytesSource: null,
expectedBytesMismatch: false,
downloadedBytes: 0,
+ speedBps: null,
attempts: 0,
status: 'downloading',
startedAt: '2026-06-06T00:00:00.000Z',
diff --git a/apps/backend/src/__tests__/upload-tracking.test.ts b/apps/backend/src/__tests__/upload-tracking.test.ts
index 1e3818f..b0b1be9 100644
--- a/apps/backend/src/__tests__/upload-tracking.test.ts
+++ b/apps/backend/src/__tests__/upload-tracking.test.ts
@@ -1,3 +1,4 @@
+import type { AuthVariables } from '../middleware/require-auth'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -6,57 +7,11 @@ import { Hono } from 'hono'
import { openMemoryDatabase } from '../database/connection'
import { PeerController } from '../modules/peer/peer.controller'
import { getPeerRouter } from '../modules/peer/peer.router'
+import { enrichDownload } from '../modules/status/status.controller'
import { UploadsRepository } from '../modules/uploads/uploads.repository'
import { UploadsService } from '../modules/uploads/uploads.service'
-const input = {
- peerName: 'alice',
- peerKeyId: 7,
- itemId: 'radarr1:movie:1',
- filename: 'Movie.mkv',
- totalBytes: 12,
- rangeStart: 0,
- requestedBytes: 12,
-}
-
-describe('UploadsService', () => {
- test('a disconnect aborts the row; a finished response leaves it for the receipt', () => {
- const repository = new UploadsRepository(openMemoryDatabase())
- const service = new UploadsService(repository)
- const controller = new AbortController()
- const dropped = service.start(input, true, controller.signal)!
- const finished = service.start(input, true, new AbortController().signal)!
-
- controller.abort()
-
- expect(repository.get(dropped)).toMatchObject({ status: 'aborted', error: 'peer disconnected' })
- expect(repository.get(finished)?.status).toBe('uploading')
- expect(service.receipt(finished, 7, 12, 4000)).toBe(true)
- expect(repository.get(finished)).toMatchObject({ status: 'completed', sentBytes: 12, durationMs: 4000 })
- })
-
- test('a peer that left before the row existed is aborted right away', () => {
- const repository = new UploadsRepository(openMemoryDatabase())
- const id = new UploadsService(repository).start(input, true, AbortSignal.abort())!
- expect(repository.get(id)?.status).toBe('aborted')
- })
-
- test('a peer that sends no receipt starts unconfirmed', () => {
- const repository = new UploadsRepository(openMemoryDatabase())
- const id = new UploadsService(repository).start(input, false)!
- expect(repository.get(id)?.status).toBe('unconfirmed')
- })
-
- test('a failed insert serves the file untracked instead of throwing', () => {
- const repository = new UploadsRepository(openMemoryDatabase())
- repository.create = () => {
- throw new Error('database or disk is full')
- }
- expect(new UploadsService(repository).start(input, true)).toBeUndefined()
- })
-})
-
-describe('peer file route and receipt', () => {
+describe('peer transfers', () => {
let tempDir: string
let filePath: string
@@ -79,59 +34,120 @@ describe('peer file route and receipt', () => {
getFilePath: async () => filePath,
}
const repository = new UploadsRepository(openMemoryDatabase())
- const app = new Hono()
+ const app = new Hono<{ Variables: AuthVariables }>()
+ // Stands in for require-auth: the `X-Key` header picks the api key row.
+ app.use(async (c, next) => {
+ const key = c.req.header('X-Key')
+ if (key)
+ c.set('apiKeyId', Number(key))
+ await next()
+ })
app.route('/peer', getPeerRouter(new PeerController(() => [source as any]), new UploadsService(repository)))
- return { app, repository }
+ const create = (clientTransferId: string, key = '7') => app.request('/peer/items/remote1:movie:1/downloads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Key': key },
+ body: JSON.stringify({ clientTransferId }),
+ })
+ return { app, repository, create }
}
- test('serves a range request, then the receipt completes its row', async () => {
- const { app, repository } = setup()
- const res = await app.request('/peer/items/remote1:movie:1/file', {
- headers: { 'Range': 'bytes=6-', 'X-Jack-Receipt': '1' },
+ test('one transfer through a dropped request, a resume and the receipt', async () => {
+ const { app, repository, create } = setup()
+ const res = await create('download-1')
+ expect(res.status).toBe(201)
+ const transfer = await res.json() as { downloadId: string, url: string, totalBytes: number }
+ expect(transfer).toMatchObject({ filename: 'Movie.mkv', totalBytes: 10, url: `/peer/downloads/${transfer.downloadId}/file` })
+
+ // A lost 201: creating it again returns the same transfer.
+ const again = await create('download-1')
+ expect(again.status).toBe(200)
+ expect((await again.json() as { downloadId: string }).downloadId).toBe(transfer.downloadId)
+
+ // The first request drops after a few bytes; the transfer stays open.
+ const first = await app.request(transfer.url, {
+ headers: { 'X-Key': '7' },
})
+ await first.body?.cancel()
+ const resumed = await app.request(transfer.url, {
+ headers: { 'Range': 'bytes=6-', 'X-Key': '7' },
+ })
+ expect(resumed.status).toBe(206)
expect([
- ...new Uint8Array(await res.arrayBuffer()),
+ ...new Uint8Array(await resumed.arrayBuffer()),
]).toEqual([6, 7, 8, 9])
- const uploadId = res.headers.get('X-Jack-Upload-Id')
expect(repository.list()).toEqual([
- expect.objectContaining({
- peerName: 'master',
- totalBytes: 10,
- rangeStart: 6,
- requestedBytes: 4,
- status: 'uploading',
- }),
+ expect.objectContaining({ status: 'uploading', rangeStart: 0, totalBytes: 10 }),
])
- const receipt = await app.request(`/peer/uploads/${uploadId}/receipt`, {
+ const receipt = await app.request(`/peer/downloads/${transfer.downloadId}/receipt`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ bytes: 4, durationMs: 2 }),
+ headers: { 'X-Key': '7' },
})
expect(receipt.status).toBe(204)
- expect(repository.list()[0]).toMatchObject({ status: 'completed', sentBytes: 4, durationMs: 2 })
+ expect(repository.list()).toEqual([
+ expect.objectContaining({ status: 'completed', sentBytes: 10 }),
+ ])
})
- test('an unknown receipt id is ignored, a malformed body rejected', async () => {
- const { app } = setup()
- const unknown = await app.request('/peer/uploads/999/receipt', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ bytes: 4, durationMs: 2 }),
+ test('another key can neither stream nor complete a transfer', async () => {
+ const { app, repository, create } = setup()
+ const { downloadId, url } = await (await create('download-1')).json() as { downloadId: string, url: string }
+ expect((await app.request(url, {
+ headers: { 'X-Key': '8' },
+ })).status).toBe(404)
+ await app.request(url, {
+ headers: { 'X-Key': '7' },
})
- expect(unknown.status).toBe(204)
- const malformed = await app.request('/peer/uploads/1/receipt', {
+ expect((await app.request(`/peer/downloads/${downloadId}/receipt`, {
+ method: 'POST',
+ headers: { 'X-Key': '8' },
+ })).status).toBe(204)
+ expect(repository.list()[0]?.status).toBe('uploading')
+ })
+
+ test('a client transfer id reused for another item is a conflict', async () => {
+ const { app, create } = setup()
+ await create('download-1')
+ const res = await app.request('/peer/items/remote1:movie:2/downloads', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ bytes: -1 }),
+ headers: { 'Content-Type': 'application/json', 'X-Key': '7' },
+ body: JSON.stringify({ clientTransferId: 'download-1' }),
})
- expect(malformed.status).toBe(400)
+ expect(res.status).toBe(409)
})
- test('does not record a HEAD request', async () => {
- const { app, repository } = setup()
- const res = await app.request('/peer/items/remote1:movie:1/file', { method: 'HEAD' })
- expect(res.headers.get('X-Jack-Upload-Id')).toBeNull()
+ test('the item file route serves without recording, and a HEAD serves nothing', async () => {
+ const { app, repository, create } = setup()
+ const legacy = await app.request('/peer/items/remote1:movie:1/file', {
+ headers: { Range: 'bytes=6-' },
+ })
+ expect([
+ ...new Uint8Array(await legacy.arrayBuffer()),
+ ]).toEqual([6, 7, 8, 9])
expect(repository.list()).toEqual([])
+
+ const { url } = await (await create('download-1')).json() as { url: string }
+ await app.request(url, {
+ method: 'HEAD',
+ headers: { 'X-Key': '7' },
+ })
+ expect(repository.list()[0]?.rangeStart).toBeNull()
})
+
+ test('a malformed create is rejected', async () => {
+ const { create } = setup()
+ expect((await create('')).status).toBe(400)
+ })
+})
+
+test('download speed and ETA come from the row, only while it runs', () => {
+ const row = {
+ status: 'downloading',
+ expectedBytes: 10_000,
+ releaseSize: 10_000,
+ downloadedBytes: 2000,
+ speedBps: 2000,
+ } as Parameters[0]
+ expect(enrichDownload(row)).toMatchObject({ progress: 0.2, speedBps: 2000, etaSeconds: 4 })
+ expect(enrichDownload({ ...row, status: 'import_queued' })).toMatchObject({ speedBps: null, etaSeconds: null })
})
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
index 4d10c55..eaaa95b 100644
--- a/apps/backend/src/__tests__/uploads-repository.test.ts
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -12,109 +12,119 @@ const input = {
itemId: 'radarr1:movie:1',
filename: 'Movie.mkv',
totalBytes: 100,
- rangeStart: 40,
- requestedBytes: 60,
+ clientTransferId: 'download-1',
}
describe('UploadsRepository', () => {
- test('creates a row with nothing sent yet', () => {
- const row = makeRepo().create(input, 'uploading')
- expect(row).toMatchObject({
- ...input,
+ test('creates an open transfer with nothing served yet', () => {
+ const { upload, created } = makeRepo().create(input)
+ expect(created).toBe(true)
+ expect(upload).toMatchObject({
+ peerName: 'alice',
+ peerKeyId: 7,
+ totalBytes: 100,
+ rangeStart: null,
sentBytes: 0,
durationMs: null,
status: 'uploading',
- error: null,
finishedAt: null,
})
+ expect(upload.transferId).toMatch(/^[0-9a-f-]{36}$/)
})
- test('a receipt from the same peer completes it with bytes and duration', () => {
+ test('creating the same transfer again returns it; ids are scoped per key', () => {
const repo = makeRepo()
- const { id } = repo.create(input, 'uploading')
-
- // Another key, even one with the same name, can't close it; nor can the master key.
- expect(repo.complete(id, 8, 60, 1000)).toBe(false)
- expect(repo.complete(id, null, 60, 1000)).toBe(false)
- // Anything but the requested 60 bytes is rejected.
- expect(repo.complete(id, 7, 61, 1000)).toBe(false)
- expect(repo.complete(id, 7, 59, 1000)).toBe(false)
- expect(repo.complete(id, 7, 60, 2000)).toBe(true)
- expect(repo.get(id)).toMatchObject({ status: 'completed', sentBytes: 60, durationMs: 2000 })
- expect(repo.get(id)?.finishedAt).not.toBeNull()
- // Closed rows stay closed.
- expect(repo.complete(id, 7, 1, 1)).toBe(false)
+ const first = repo.create(input).upload
+ expect(repo.create(input)).toEqual({ upload: first, created: false })
+ expect(repo.create({ ...input, peerKeyId: 8 }).created).toBe(true)
+ expect(repo.create({ ...input, peerKeyId: null }).created).toBe(true)
+ expect(repo.create({ ...input, peerKeyId: null }).created).toBe(false)
+ expect(repo.list()).toHaveLength(3)
})
- test('an older peer\'s row can still be completed or aborted', () => {
+ test('only the owning key finds a transfer', () => {
const repo = makeRepo()
- const a = repo.create(input, 'unconfirmed')
- const b = repo.create(input, 'unconfirmed')
- repo.markAborted(a.id, 'peer disconnected')
- expect(repo.complete(b.id, 7, 60, 10)).toBe(true)
- expect(repo.get(a.id)?.status).toBe('aborted')
+ const { transferId } = repo.create(input).upload
+ expect(repo.findOwned(transferId, 7)?.transferId).toBe(transferId)
+ expect(repo.findOwned(transferId, 8)).toBeNull()
+ expect(repo.findOwned(transferId, null)).toBeNull()
})
- test('a disconnect after completion changes nothing', () => {
+ test('the receipt completes it with the bytes served since the lowest range, from creation', () => {
const repo = makeRepo()
- const { id } = repo.create(input, 'uploading')
- repo.complete(id, 7, 60, 10)
- repo.markAborted(id, 'peer disconnected')
- expect(repo.get(id)?.status).toBe('completed')
+ const { id, transferId, startedAt } = repo.create(input).upload
+ const now = Date.parse(startedAt) + 5000
+
+ // Never requested: nothing was sent, so there is nothing to complete.
+ expect(repo.complete(transferId, 7, now)).toBe(false)
+ repo.served(id, 60)
+ repo.served(id, 40)
+ repo.served(id, 70)
+ // Another key, even one with the same name, can't close it; nor can the master key.
+ expect(repo.complete(transferId, 8, now)).toBe(false)
+ expect(repo.complete(transferId, null, now)).toBe(false)
+ expect(repo.complete(transferId, 7, now)).toBe(true)
+ expect(repo.get(id)).toMatchObject({
+ status: 'completed',
+ rangeStart: 40,
+ sentBytes: 60,
+ durationMs: 5000,
+ })
+ // Closed rows stay closed, and serving them again changes nothing.
+ expect(repo.complete(transferId, 7, now)).toBe(false)
+ repo.served(id, 0)
+ expect(repo.get(id)?.rangeStart).toBe(40)
})
- test('expires rows that waited too long for a receipt', () => {
+ test('expires transfers that waited too long; a late receipt still completes one', () => {
const repo = makeRepo()
- const { id } = repo.create(input, 'uploading')
+ const { id, transferId } = repo.create(input).upload
+ repo.served(id, 0)
expect(repo.expireUnconfirmed('2000-01-01T00:00:00Z')).toBe(0)
expect(repo.expireUnconfirmed('9999-01-01T00:00:00Z')).toBe(1)
expect(repo.get(id)?.status).toBe('unconfirmed')
+ expect(repo.complete(transferId, 7)).toBe(true)
+ expect(repo.get(id)).toMatchObject({ status: 'completed', sentBytes: 100 })
})
- test('summarizes in SQL and lists only running uploads as active', () => {
+ test('summarizes in SQL and lists only open transfers as active', () => {
const repo = makeRepo()
- const running = repo.create(input, 'uploading')
- const done = repo.create(input, 'uploading')
- repo.complete(done.id, 7, 60, 10)
- repo.create(input, 'unconfirmed')
+ repo.create({ ...input, clientTransferId: 'a' })
+ const done = repo.create({ ...input, clientTransferId: 'b' }).upload
+ repo.served(done.id, 40)
+ repo.complete(done.transferId, 7)
+ repo.create({ ...input, clientTransferId: 'c' })
+ // Expires a and c; d is the only one still open.
+ repo.expireUnconfirmed('9999-01-01T00:00:00Z')
+ const open = repo.create({ ...input, clientTransferId: 'd' }).upload
expect(repo.summary()).toEqual({
byStatus: {
uploading: 1,
completed: 1,
- aborted: 0,
- unconfirmed: 1,
+ unconfirmed: 2,
},
- total: 3,
+ total: 4,
bytesSent: 60,
})
- expect(repo.listActive().map(r => r.id)).toEqual([running.id])
+ expect(repo.listActive().map(r => r.id)).toEqual([open.id])
})
test('lists newest first', () => {
const repo = makeRepo()
- const first = repo.create(input, 'uploading')
- const second = repo.create(input, 'uploading')
+ const first = repo.create({ ...input, clientTransferId: 'a' }).upload
+ const second = repo.create({ ...input, clientTransferId: 'b' }).upload
expect(repo.list().map(r => r.id)).toEqual([second.id, first.id])
})
- test('marks rows left uploading by a previous run as aborted', () => {
- const repo = makeRepo()
- const running = repo.create(input, 'uploading')
- const done = repo.create(input, 'uploading')
- repo.complete(done.id, 7, 60, 10)
-
- expect(repo.reconcileInterrupted()).toBe(1)
- expect(repo.get(running.id)).toMatchObject({ status: 'aborted', error: 'interrupted by a jack restart' })
- expect(repo.get(done.id)?.status).toBe('completed')
- })
-
test('notifies subscribers on every change', () => {
const repo = makeRepo()
let calls = 0
repo.subscribe(() => calls++)
- const { id } = repo.create(input, 'uploading')
- repo.complete(id, 7, 60, 10)
- expect(calls).toBe(2)
+ const { id, transferId } = repo.create(input).upload
+ repo.create(input)
+ repo.served(id, 0)
+ repo.complete(transferId, 7)
+ expect(calls).toBe(3)
})
})
diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts
index d3ed165..e8b0353 100644
--- a/apps/backend/src/database/schema.ts
+++ b/apps/backend/src/database/schema.ts
@@ -32,6 +32,9 @@ export const downloads = sqliteTable('downloads', {
expectedBytesSource: text('expected_bytes_source').$type(),
expectedBytesMismatch: integer('expected_bytes_mismatch', { mode: 'boolean' }).notNull().default(false),
downloadedBytes: integer('downloaded_bytes').notNull().default(0),
+ // Bytes per second between the last two progress writes; null until there are
+ // two in the current attempt.
+ speedBps: integer('speed_bps'),
attempts: integer('attempts').notNull().default(0),
status: text('status').$type().notNull(),
startedAt: text('started_at').notNull(),
@@ -81,24 +84,28 @@ export const apiKeys = sqliteTable('api_keys', {
updatedAt: text('updated_at').notNull(),
})
-// Files peers pull from this instance through `/peer/items/:id/file`. One row
-// per HTTP request: a resumed transfer sends a Range header and gets its own
-// row, starting at `rangeStart`.
+// Files peers pull from this instance. One row per transfer, which a peer
+// creates with `POST /peer/items/:itemId/downloads` and then streams, resumes
+// and retries as often as it needs through `/peer/downloads/:downloadId/file`.
//
-// Files go out zero-copy, so jack never counts the bytes itself. It learns an
-// upload ended early from the disconnect (`aborted`), and that it finished from
-// the receipt the downloading jack posts back (`completed`, with its duration).
-// No receipt (an older peer, or a lost POST) leaves it `unconfirmed`.
+// Files go out zero-copy, so jack never counts the bytes itself. A dropped
+// request leaves the transfer open to resume; the receipt the downloading jack
+// posts once the file arrived completes it. No receipt within 48 hours leaves
+// it `unconfirmed`.
export const UPLOAD_STATUSES = [
'uploading',
'completed',
- 'aborted',
'unconfirmed',
] as const
export type UploadStatus = typeof UPLOAD_STATUSES[number]
export const uploads = sqliteTable('uploads', {
id: integer('id').primaryKey({ autoIncrement: true }),
+ // The opaque `downloadId` peers address the transfer by.
+ transferId: text('transfer_id').notNull().unique(),
+ // `:`: creating the same transfer again
+ // (a retry after a lost response) returns this row instead of a new one.
+ idempotencyKey: text('idempotency_key').notNull().unique(),
// Name of the api key the peer authenticated with ('master' for the master key).
// Display only: names are optional, editable and not unique.
peerName: text('peer_name').notNull(),
@@ -109,21 +116,19 @@ export const uploads = sqliteTable('uploads', {
filename: text('filename').notNull(),
// Size of the whole file on disk.
totalBytes: integer('total_bytes').notNull(),
- // First byte this request serves (0 without a Range header).
- rangeStart: integer('range_start').notNull().default(0),
- // Bytes this request was asked to send (the response Content-Length).
- requestedBytes: integer('requested_bytes').notNull(),
- // Bytes the peer says it received; 0 until its receipt arrives.
+ // Lowest byte any request for this transfer started at (a resume of a .part
+ // the peer already had starts past 0). Null until the file is first requested.
+ rangeStart: integer('range_start'),
+ // Bytes this transfer delivered, `totalBytes - rangeStart`; 0 until the receipt.
sentBytes: integer('sent_bytes').notNull().default(0),
- // Transfer time the peer reported; null without a receipt, so speed is unknown.
+ // Creation to receipt, retries and resumes included; null without a receipt.
durationMs: integer('duration_ms'),
status: text('status').$type().notNull(),
- error: text('error'),
startedAt: text('started_at').notNull(),
updatedAt: text('updated_at').notNull(),
finishedAt: text('finished_at'),
}, t => [
- check('uploads_status_check', sql`${t.status} in ('uploading', 'completed', 'aborted', 'unconfirmed')`),
+ check('uploads_status_check', sql`${t.status} in ('uploading', 'completed', 'unconfirmed')`),
index('uploads_status_idx').on(t.status),
index('uploads_started_at_idx').on(t.startedAt),
])
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index 963250d..c7657dd 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -55,10 +55,6 @@ const catalogCacheRepository = new CatalogCacheRepository(database.db)
const tmdbMetadataRepository = new TmdbMetadataRepository(database.db)
const uploadsService = new UploadsService(new UploadsRepository(database.db))
-// Peers connected to the previous run lost those transfers with it.
-const interruptedUploads = uploadsService.repository.reconcileInterrupted()
-if (interruptedUploads > 0)
- logger.warn({ uploads: interruptedUploads }, 'Marked uploads interrupted by the previous jack run as aborted')
// A receipt that never came (a lost POST) shouldn't leave an upload running forever.
setInterval(() => uploadsService.expireUnconfirmed(), 60 * 60 * 1000).unref()
diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts
index 82c0cd7..fe8ccb2 100644
--- a/apps/backend/src/lib/servers/peer.ts
+++ b/apps/backend/src/lib/servers/peer.ts
@@ -13,7 +13,7 @@ import { PeerCatalogResponse } from '../peer-catalog'
import { normalizeImdbId, Release } from '../release'
import { setSpanAttribute, setSpanAttributes } from '../span-attributes'
import { withSpan } from '../tracing'
-import { compareVersions, isPeerVersionCompatible, MIN_PEER_PROTOCOL_VERSION, PEER_CATALOG_SUMMARY_VERSION } from '../version'
+import { compareVersions, isPeerVersionCompatible, MIN_PEER_PROTOCOL_VERSION, PEER_CATALOG_SUMMARY_VERSION, PEER_TRANSFERS_VERSION } from '../version'
import { ServerConnector } from './base'
const PeerSearchResponse = z.object({
@@ -22,14 +22,16 @@ const PeerSearchResponse = z.object({
const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 * 1024 // 100GB
const DOWNLOAD_PROGRESS_INTERVAL_MS = 10_000
const DOWNLOAD_PROGRESS_BYTES = 64 * 1024 * 1024
-const DOWNLOAD_LIVE_INTERVAL_MS = 1000
const CONTENT_RANGE_PATTERN = /^bytes (\d+)-(\d+)\/(\d+)$/
+const PeerTransfer = z.object({
+ downloadId: z.string(),
+ url: z.string(),
+})
+
export type PeerDownloadProgressEvent
= | { type: 'headers', expectedBytes: number | null, expectedBytesSource: 'content_length' | 'content_range' | 'release_size' | null, expectedBytesMismatch: boolean }
| { type: 'progress', downloadedBytes: number, expectedBytes: number | null }
- // About once a second, for live progress only; `progress` is the durable one.
- | { type: 'bytes', downloadedBytes: number }
| { type: 'restart', reason: 'range_ignored' | 'content_range_mismatch' | 'range_not_satisfiable' | 'part_oversize', discardedBytes: number }
| { type: 'completed', downloadedBytes: number, expectedBytes: number | null }
@@ -39,6 +41,9 @@ export interface PeerDownloadOptions {
torrentFilename?: string
partPath?: string
releaseSize?: number
+ // Stable for every attempt at one download, so the peer keeps a single
+ // transfer for it however often it is retried or resumed.
+ transferId?: string
onProgress?: (event: PeerDownloadProgressEvent) => void | Promise
}
@@ -258,32 +263,70 @@ export class PeerConnector extends ServerConnector {
}
/**
- * Tell the serving jack this file arrived and how long it took. It sends files
- * zero-copy, so it can see an early disconnect but not the end of a transfer.
- * Fire and forget: a lost receipt only leaves that upload's speed unknown, and
- * a peer too old to issue upload ids gets none.
+ * Where to stream item `id` from. A peer on protocol 0.3.0+ gets a transfer it
+ * records as an upload; `transferId` makes creating it idempotent, so every
+ * attempt at one download reuses the same transfer. Older peers, or a create
+ * that fails (say the peer's database is full), fall back to the untracked
+ * file route: bookkeeping never blocks a download.
*/
- private sendUploadReceipt(uploadId: string | null, bytes: number, durationMs: number): void {
- if (!uploadId || !/^\d+$/.test(uploadId))
+ private async openTransfer(id: string, transferId: string | undefined, signal: AbortSignal): Promise<{ url: URL, downloadId: string | null }> {
+ const legacy = {
+ url: new URL(`/peer/items/${encodeURIComponent(id)}/file`, this.url),
+ downloadId: null,
+ }
+ if (!transferId || !this._peerVersion || compareVersions(this._peerVersion, PEER_TRANSFERS_VERSION) < 0)
+ return legacy
+ try {
+ const transfer = await this.fetch(`/peer/items/${encodeURIComponent(id)}/downloads`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ clientTransferId: transferId }),
+ schema: PeerTransfer,
+ signal: AbortSignal.any([
+ signal,
+ AbortSignal.timeout(30_000),
+ ]),
+ })
+ return {
+ url: new URL(transfer.url, this.url),
+ downloadId: transfer.downloadId,
+ }
+ }
+ catch (err) {
+ signal.throwIfAborted()
+ logger.debug({
+ peer: this.name,
+ id,
+ error: err instanceof Error
+ ? err.message
+ : String(err),
+ }, 'Could not start a transfer; downloading untracked')
+ return legacy
+ }
+ }
+
+ /**
+ * Tell the serving jack the whole file arrived. It sends files zero-copy, so it
+ * can't see a transfer end. Fire and forget: a lost receipt only leaves that
+ * upload unconfirmed.
+ */
+ private sendUploadReceipt(downloadId: string | null): void {
+ if (!downloadId)
return
- const url = new URL(`/peer/uploads/${uploadId}/receipt`, this.url)
+ const url = new URL(`/peer/downloads/${encodeURIComponent(downloadId)}/receipt`, this.url)
fetch(url, {
method: 'POST',
- headers: { ...this.headers, 'X-Api-Key': this.apiKey, 'Content-Type': 'application/json' },
- body: JSON.stringify({
- bytes,
- durationMs: Math.round(durationMs),
- }),
+ headers: { ...this.headers, 'X-Api-Key': this.apiKey },
signal: AbortSignal.timeout(10_000),
})
.then((response) => {
if (!response.ok)
- logger.debug({ peer: this.name, uploadId, status: response.status }, 'Peer did not accept the upload receipt')
+ logger.debug({ peer: this.name, downloadId, status: response.status }, 'Peer did not accept the upload receipt')
})
.catch((err) => {
logger.debug({
peer: this.name,
- uploadId,
+ downloadId,
error: err instanceof Error
? err.message
: String(err),
@@ -301,11 +344,15 @@ export class PeerConnector extends ServerConnector {
}, async (span) => {
const idleTimeoutMs = options.idleTimeoutMs ?? 60_000
const torrentFilename = options.torrentFilename
- const url = new URL(`/peer/items/${encodeURIComponent(id)}/file`, this.url)
+ // The untracked file route until the transfer is opened, right before the
+ // first request (a .part that is already complete needs none).
+ let transfer: { url: URL, downloadId: string | null } = {
+ url: new URL(`/peer/items/${encodeURIComponent(id)}/file`, this.url),
+ downloadId: null,
+ }
const partPath = options.partPath ?? `${destPath}.part`
- // X-Jack-Receipt: we'll report back when the file arrived (see sendUploadReceipt).
- const baseHeaders = { ...this.headers, 'X-Api-Key': this.apiKey, 'X-Jack-Receipt': '1' }
- setSpanAttributes(span, { 'http.request.idle_timeout_ms': idleTimeoutMs, 'url.path': url.pathname })
+ const baseHeaders = { ...this.headers, 'X-Api-Key': this.apiKey }
+ setSpanAttribute(span, 'http.request.idle_timeout_ms', idleTimeoutMs)
// Idle (inactivity) timeout, armed ONLY around network waits (fetch + each
// read) and cleared before local file/progress work, so slow disk I/O never
@@ -340,7 +387,7 @@ export class PeerConnector extends ServerConnector {
signal.throwIfAborted()
armIdle()
try {
- return await fetch(url, {
+ return await fetch(transfer.url, {
headers: withRange
? { ...baseHeaders, Range: `bytes=${existingBytes}-` }
: baseHeaders,
@@ -419,6 +466,8 @@ export class PeerConnector extends ServerConnector {
}
}
+ transfer = await this.openTransfer(id, options.transferId, signal)
+ setSpanAttribute(span, 'url.path', transfer.url.pathname)
let response = await doFetch(existingBytes > 0)
setSpanAttribute(span, 'http.response.status_code', response.status)
@@ -534,12 +583,8 @@ export class PeerConnector extends ServerConnector {
throw err
}
let downloadedBytes = existingBytes
- // What this request carries, for the receipt: a resume only counts its range.
- const requestStartBytes = existingBytes
- const requestStartedAt = performance.now()
let lastLoggedAt = Date.now()
let lastLoggedBytes = downloadedBytes
- let lastLiveAt = 0
let handleClosed = false
const closeHandle = async () => {
@@ -607,11 +652,6 @@ export class PeerConnector extends ServerConnector {
lastLoggedAt = now
lastLoggedBytes = downloadedBytes
}
- // Cheap in-memory tick for live progress and speed: no fsync, no DB write.
- if (now - lastLiveAt >= DOWNLOAD_LIVE_INTERVAL_MS) {
- await options.onProgress?.({ type: 'bytes', downloadedBytes })
- lastLiveAt = now
- }
}
await syncPart(`flushing the .part to disk after the last chunk (${downloadedBytes} bytes written)`)
@@ -629,7 +669,7 @@ export class PeerConnector extends ServerConnector {
signal.throwIfAborted()
await withFsContext('move_completed', 'moving the completed .part into place', [partPath, destPath], () => rename(partPath, destPath))
setSpanAttribute(span, 'download.downloaded_bytes', downloadedBytes)
- this.sendUploadReceipt(response.headers.get('X-Jack-Upload-Id'), downloadedBytes - requestStartBytes, performance.now() - requestStartedAt)
+ this.sendUploadReceipt(transfer.downloadId)
try {
await options.onProgress?.({ type: 'completed', downloadedBytes, expectedBytes })
}
diff --git a/apps/backend/src/lib/version.ts b/apps/backend/src/lib/version.ts
index 9044a24..7dd2252 100644
--- a/apps/backend/src/lib/version.ts
+++ b/apps/backend/src/lib/version.ts
@@ -1,6 +1,6 @@
// jack's own version, reported to peers over /handshake. This doubles as the
// peer-protocol version: a bump here signals a potential protocol change.
-export const PROTOCOL_VERSION = '0.2.0'
+export const PROTOCOL_VERSION = '0.3.0'
// Oldest peer version we can still talk to. Peers below this — or peers too old
// to expose a version at all — are rejected at init time as incompatible.
@@ -10,6 +10,10 @@ export const MIN_PEER_PROTOCOL_VERSION = '0.1.0'
// only have `/peer/search`, so their catalog is summarized from full releases.
export const PEER_CATALOG_SUMMARY_VERSION = '0.2.0'
+// First version with transfers (`POST /peer/items/:itemId/downloads`), which it
+// records as uploads. Older peers only serve the untracked `/items/:itemId/file`.
+export const PEER_TRANSFERS_VERSION = '0.3.0'
+
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/
function parseVersion(version: string): [number, number, number] | null {
diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts
index ffedb5e..3fab280 100644
--- a/apps/backend/src/modules/downloads/downloads.repository.ts
+++ b/apps/backend/src/modules/downloads/downloads.repository.ts
@@ -22,6 +22,8 @@ export interface DownloadRecord {
expectedBytesSource: ExpectedBytesSource | null
expectedBytesMismatch: boolean
downloadedBytes: number
+ // Bytes per second between the last two progress writes of this attempt.
+ speedBps: number | null
attempts: number
status: DownloadStatus
startedAt: string
@@ -78,6 +80,7 @@ function toRecord(row: DownloadRow): DownloadRecord {
expectedBytesSource: row.expectedBytesSource ?? null,
expectedBytesMismatch: row.expectedBytesMismatch,
downloadedBytes: row.downloadedBytes,
+ speedBps: row.speedBps,
attempts: row.attempts,
status: row.status,
startedAt: row.startedAt,
@@ -213,10 +216,11 @@ export class DownloadsRepository {
this.notifyChanged()
}
- updateProgress(id: number, downloadedBytes: number): void {
+ updateProgress(id: number, downloadedBytes: number, speedBps: number | null = null): void {
this.db.update(downloads)
.set({
downloadedBytes,
+ speedBps,
updatedAt: nowIso(),
})
.where(eq(downloads.id, id))
@@ -314,6 +318,8 @@ export class DownloadsRepository {
const row = this.db.update(downloads)
.set({
attempts: sql`${downloads.attempts} + 1`,
+ // A new attempt has no samples yet.
+ speedBps: null,
updatedAt: nowIso(),
})
.where(eq(downloads.id, id))
@@ -343,6 +349,7 @@ export class DownloadsRepository {
this.db.update(downloads)
.set({
downloadedBytes: 0,
+ speedBps: null,
error: 'resume validation failed; restarted from byte 0',
updatedAt: nowIso(),
})
diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts
index ae7976d..7ba138a 100644
--- a/apps/backend/src/modules/downloads/downloads.service.ts
+++ b/apps/backend/src/modules/downloads/downloads.service.ts
@@ -14,7 +14,6 @@ import { logger } from '../../logger'
import { unlinkDownloadArtifact } from './artifact-cleanup'
import { transferErrorCode, transferErrorLogFields } from './download-error-codes'
import { coordinatorFor } from './download-operation-coordinator'
-import { liveProgressFor } from './live-progress'
import { downloadRetryAfterMs, isTransientDownloadError } from './retry-policy'
type DownloadsServiceConfig = NonNullable
@@ -145,6 +144,7 @@ export class DownloadsService {
expectedBytesSource: null,
expectedBytesMismatch: false,
downloadedBytes: 0,
+ speedBps: null,
attempts: 0,
status: 'downloading',
startedAt: '',
@@ -381,14 +381,13 @@ export class DownloadsService {
}
finally {
this.active.delete(record.destPath)
- if (this.downloadsRepository)
- liveProgressFor(this.downloadsRepository).clear(record.id)
}
}
private async downloadWithRetry(record: DownloadRecord, signal: AbortSignal): Promise {
const repo = this.downloadsRepository
- const live = repo && liveProgressFor(repo)
+ // The previous progress sample in this attempt, to work out the speed from.
+ let last: { at: number, bytes: number } | undefined
const peer = this.peers.find(p => p.id === record.peerId)
if (!peer) {
repo?.markFailed(record.id, 'PEER_NOT_FOUND', `Peer ${record.peerId} not found`)
@@ -401,33 +400,36 @@ export class DownloadsService {
repo?.setExpectedBytes(record.id, event.expectedBytes, event.expectedBytesSource, event.expectedBytesMismatch)
return
}
- if (event.type === 'bytes') {
- live?.record(record.id, event.downloadedBytes)
- return
- }
if (event.type === 'progress') {
- repo?.updateProgress(record.id, event.downloadedBytes)
- live?.record(record.id, event.downloadedBytes)
+ const at = Date.now()
+ const speedBps = last && at > last.at
+ ? Math.round(((event.downloadedBytes - last.bytes) * 1000) / (at - last.at))
+ : null
+ last = { at, bytes: event.downloadedBytes }
+ repo?.updateProgress(record.id, event.downloadedBytes, speedBps)
return
}
if (event.type === 'restart') {
+ last = undefined
repo?.markResumeReset(record.id)
- live?.clear(record.id)
return
}
// event.type === 'completed': the transfer finished. Record the final byte
// count; the status flips to import_queued once downloadFile resolves below.
- repo?.updateProgress(record.id, event.downloadedBytes)
+ repo?.updateProgress(record.id, event.downloadedBytes, null)
}
try {
await retry(async () => {
signal.throwIfAborted()
+ last = undefined
repo?.incrementAttempts(record.id)
await peer.downloadFile(record.itemId, record.destPath, {
torrentFilename: record.torrentFilename,
partPath: record.partPath,
releaseSize: record.releaseSize,
+ // Stable for this download across retries and restarts.
+ transferId: `download-${record.id}-${Date.parse(record.startedAt)}`,
idleTimeoutMs: this.config.idleTimeoutMs,
onProgress,
signal,
diff --git a/apps/backend/src/modules/downloads/live-progress.ts b/apps/backend/src/modules/downloads/live-progress.ts
deleted file mode 100644
index 29793bb..0000000
--- a/apps/backend/src/modules/downloads/live-progress.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import type { DownloadsRepository } from './downloads.repository'
-
-// Speed is averaged over this much recent history.
-const SPEED_WINDOW_MS = 10_000
-// Live updates fire at most this often, so the SSE stream (a full snapshot per
-// notify) stays at about one frame per second however many transfers run.
-const NOTIFY_INTERVAL_MS = 1000
-// No sample for this long means the transfer stalled or is waiting to retry: the
-// byte count stays, the speed goes.
-const STALE_MS = 5000
-
-export interface LiveProgress {
- downloadedBytes: number
- /** Bytes per second over the recent window, or null until two samples exist. */
- speedBps: number | null
-}
-
-/**
- * In-memory byte counts for running transfers, fed about once a second by the
- * peer download loop. The database only stores progress every 10 s / 64 MiB
- * (each write also fsyncs the .part), so this is what lets the UI move the bar
- * and show a speed in between. Nothing here is persisted.
- */
-export class LiveProgressTracker {
- private readonly samples = new Map()
- private readonly subscribers = new Set<() => void>()
- private notifyTimer?: ReturnType
- // Fires once samples stop coming, so open pages drop a speed that went stale.
- private staleTimer?: ReturnType
-
- constructor(private readonly now: () => number = Date.now) {}
-
- record(id: number, bytes: number): void {
- clearTimeout(this.staleTimer)
- this.staleTimer = setTimeout(() => this.scheduleNotify(), STALE_MS + NOTIFY_INTERVAL_MS)
- this.staleTimer.unref?.()
- const t = this.now()
- const kept = (this.samples.get(id) ?? []).filter(s => t - s.t <= SPEED_WINDOW_MS)
- // Fewer bytes than before means the transfer started over (restart or retry).
- const window = kept.length > 0 && bytes < kept.at(-1)!.bytes
- ? []
- : kept
- window.push({ t, bytes })
- this.samples.set(id, window)
- this.scheduleNotify()
- }
-
- clear(id: number): void {
- if (this.samples.delete(id))
- this.scheduleNotify()
- }
-
- get(id: number): LiveProgress | null {
- const window = this.samples.get(id)
- if (!window?.length)
- return null
- const first = window[0]!
- const last = window.at(-1)!
- const elapsedMs = last.t - first.t
- const stale = this.now() - last.t > STALE_MS
- return {
- downloadedBytes: last.bytes,
- speedBps: elapsedMs > 0 && !stale
- ? ((last.bytes - first.bytes) * 1000) / elapsedMs
- : null,
- }
- }
-
- subscribe(subscriber: () => void): () => void {
- this.subscribers.add(subscriber)
- return () => this.subscribers.delete(subscriber)
- }
-
- private scheduleNotify(): void {
- if (this.notifyTimer)
- return
- this.notifyTimer = setTimeout(() => {
- this.notifyTimer = undefined
- for (const subscriber of this.subscribers) {
- try {
- subscriber()
- }
- catch {
- // A broken observer must not stop the transfer that fed it.
- }
- }
- }, NOTIFY_INTERVAL_MS)
- }
-}
-
-const trackers = new WeakMap()
-
-/** One tracker per repository, shared by the service (writer) and status API (reader). */
-export function liveProgressFor(repository: DownloadsRepository): LiveProgressTracker {
- let tracker = trackers.get(repository)
- if (!tracker) {
- tracker = new LiveProgressTracker()
- trackers.set(repository, tracker)
- }
- return tracker
-}
diff --git a/apps/backend/src/modules/peer/peer.controller.ts b/apps/backend/src/modules/peer/peer.controller.ts
index 89853ec..6e02000 100644
--- a/apps/backend/src/modules/peer/peer.controller.ts
+++ b/apps/backend/src/modules/peer/peer.controller.ts
@@ -40,8 +40,9 @@ export function parseRangeHeader(value: string | undefined | null): { start?: nu
// backpressure (sendfile): if the consumer stalls or aborts mid-download, we stop
// reading from disk instead of buffering the whole file into RAM. Pumping `.stream()`
// ourselves does NOT backpressure and lets one stalled peer OOM the process. It is
-// also why upload tracking never touches the body: the downloading jack reports
-// the bytes and duration back instead (see UploadsService).
+// also why upload tracking never touches the body: jack knows where each request
+// starts, and the downloading jack's receipt says when it all arrived (see
+// UploadsService).
export type StreamFileResult
= | { type: 'full', body: Blob, size: number, filename: string }
| { type: 'partial', body: Blob, size: number, totalSize: number, start: number, end: number, filename: string }
diff --git a/apps/backend/src/modules/peer/peer.router.ts b/apps/backend/src/modules/peer/peer.router.ts
index 1b2f212..2d4db21 100644
--- a/apps/backend/src/modules/peer/peer.router.ts
+++ b/apps/backend/src/modules/peer/peer.router.ts
@@ -1,10 +1,11 @@
import type { AuthVariables } from '../../middleware/require-auth'
import type { UploadsService } from '../uploads/uploads.service'
-import type { PeerController } from './peer.controller'
+import type { PeerController, StreamFileResult } from './peer.controller'
import { Hono } from 'hono'
import { describeRoute, resolver, validator as zValidator } from 'hono-openapi'
import z from 'zod'
import { PeerCatalogResponse } from '../../lib/peer-catalog'
+import { logger } from '../../logger'
// Strip characters that could break out of the quoted filename or inject extra
// response headers (CR/LF/quote). The name comes from a trusted *arr basename, so
@@ -14,11 +15,49 @@ function safeFilename(name: string): string {
return name.replace(UNSAFE_FILENAME_CHARS, '')
}
-const UploadReceipt = z.object({
- bytes: z.number().int().min(0),
- durationMs: z.number().int().min(0),
+const CreateTransfer = z.object({
+ // Stable across the downloading jack's retries of one download, so a create
+ // whose response got lost returns the same transfer.
+ clientTransferId: z.string().min(1).max(200),
})
+const FILE_RESPONSES = {
+ 200: {
+ description: 'Full file stream',
+ content: { 'application/octet-stream': {} },
+ },
+ 206: {
+ description: 'Partial content for a satisfiable Range request',
+ content: { 'application/octet-stream': {} },
+ },
+ 416: { description: 'Unsatisfiable Range request' },
+}
+
+function fileResponse(result: StreamFileResult): Response {
+ if (result.type === 'unsatisfiable') {
+ return new Response(null, {
+ status: 416,
+ headers: {
+ 'Content-Range': `bytes */${result.totalSize}`,
+ 'Accept-Ranges': 'bytes',
+ },
+ })
+ }
+ const headers = {
+ 'Content-Type': 'application/octet-stream',
+ 'Content-Length': String(result.size),
+ 'Accept-Ranges': 'bytes',
+ 'Content-Disposition': `attachment; filename="${safeFilename(result.filename)}"`,
+ }
+ if (result.type === 'partial') {
+ return new Response(result.body, {
+ status: 206,
+ headers: { ...headers, 'Content-Range': `bytes ${result.start}-${result.end}/${result.totalSize}` },
+ })
+ }
+ return new Response(result.body, { headers })
+}
+
export function getPeerRouter(controller: PeerController, uploads?: UploadsService) {
const app = new Hono<{ Variables: AuthVariables }>()
@@ -104,106 +143,106 @@ export function getPeerRouter(controller: PeerController, uploads?: UploadsServi
app.get('/items/:itemId/file', describeRoute({
tags: ['Peer'],
- summary: 'Stream the release file',
- description: 'Streams the file straight from disk at the path the local *arr reports. Supports HTTP Range requests for resumable transfers.',
+ summary: 'Stream the release file (untracked)',
+ description: 'Streams the file straight from disk at the path the local *arr reports, with HTTP Range support. Not recorded as an upload: peers on protocol 0.3.0 and later create a transfer with `POST /peer/items/:itemId/downloads` instead. Kept for older peers.',
security: [
{ 'X-Api-Key': [] },
],
responses: {
- 200: {
- description: 'Full file stream',
- content: { 'application/octet-stream': {} },
- },
- 206: {
- description: 'Partial content for a satisfiable Range request',
- content: { 'application/octet-stream': {} },
- },
+ ...FILE_RESPONSES,
404: { description: 'No item with this id, or its file is missing on disk' },
- 416: { description: 'Unsatisfiable Range request' },
},
}), async (c) => {
- const { itemId } = c.req.param()
- const result = await controller.streamFile(itemId, c.req.header('Range'))
-
- if (!result) {
+ const result = await controller.streamFile(c.req.param('itemId'), c.req.header('Range'))
+ if (!result)
return c.json({ error: 'File not found' }, 404)
- }
-
- if (result.type === 'unsatisfiable') {
- return new Response(null, {
- status: 416,
- headers: {
- 'Content-Range': `bytes */${result.totalSize}`,
- 'Accept-Ranges': 'bytes',
- },
- })
- }
+ return fileResponse(result)
+ })
- // Record the upload; the body still goes out zero-copy. A HEAD request sends
- // no body, so it is not an upload. The master key sets no name.
- const uploadId = uploads && c.req.method !== 'HEAD'
- ? uploads.start({
- peerName: c.get('apiKeyName') ?? 'master',
- peerKeyId: c.get('apiKeyId') ?? null,
- itemId,
- filename: result.filename,
- totalBytes: result.type === 'partial'
- ? result.totalSize
- : result.size,
- rangeStart: result.type === 'partial'
- ? result.start
- : 0,
- requestedBytes: result.size,
- }, c.req.header('X-Jack-Receipt') === '1', c.req.raw.signal)
- : undefined
- // Where the downloading jack posts its receipt (see /uploads/:id/receipt).
- const uploadHeader: Record = uploadId === undefined
- ? {}
- : {
- 'X-Jack-Upload-Id': String(uploadId),
- }
+ app.post('/items/:itemId/downloads', describeRoute({
+ tags: ['Peer'],
+ summary: 'Start a transfer',
+ description: 'Creates the transfer a peer then streams from `url` and completes with a receipt. Idempotent per api key: the same `clientTransferId` returns the transfer it created before (200) rather than a new one (201). Added in protocol 0.3.0.',
+ security: [
+ { 'X-Api-Key': [] },
+ ],
+ responses: {
+ 200: { description: 'The transfer this `clientTransferId` already created' },
+ 201: { description: 'The new transfer: `downloadId`, `filename`, `totalBytes` and `url`' },
+ 404: { description: 'No item with this id, or its file is missing on disk' },
+ 409: { description: 'This `clientTransferId` already belongs to a transfer of another item' },
+ },
+ }), zValidator('json', CreateTransfer), async (c) => {
+ const itemId = c.req.param('itemId')
+ const result = await controller.streamFile(itemId)
+ if (!result || result.type !== 'full' || !uploads)
+ return c.json({ error: 'File not found' }, 404)
+ // The body is a lazy file handle; leaving it unread opens nothing.
+ const { upload, created } = uploads.repository.create({
+ peerName: c.get('apiKeyName') ?? 'master',
+ peerKeyId: c.get('apiKeyId') ?? null,
+ itemId,
+ filename: result.filename,
+ totalBytes: result.size,
+ clientTransferId: c.req.valid('json').clientTransferId,
+ })
+ if (upload.itemId !== itemId)
+ return c.json({ error: 'clientTransferId is in use for another item' }, 409)
+ return c.json({
+ downloadId: upload.transferId,
+ filename: upload.filename,
+ totalBytes: upload.totalBytes,
+ url: `/peer/downloads/${upload.transferId}/file`,
+ }, created
+ ? 201
+ : 200)
+ })
- if (result.type === 'partial') {
- return new Response(result.body, {
- status: 206,
- headers: {
- 'Content-Type': 'application/octet-stream',
- 'Content-Length': String(result.size),
- 'Content-Range': `bytes ${result.start}-${result.end}/${result.totalSize}`,
- 'Accept-Ranges': 'bytes',
- 'Content-Disposition': `attachment; filename="${safeFilename(result.filename)}"`,
- ...uploadHeader,
- },
- })
+ app.get('/downloads/:downloadId/file', describeRoute({
+ tags: ['Peer'],
+ summary: 'Stream a transfer',
+ description: 'Streams the transfer\'s file straight from disk, with HTTP Range support for resuming. Request it as often as needed: every request belongs to the same transfer, and a dropped one leaves it open. Added in protocol 0.3.0.',
+ security: [
+ { 'X-Api-Key': [] },
+ ],
+ responses: {
+ ...FILE_RESPONSES,
+ 404: { description: 'No transfer with this id for this api key, or its file is missing on disk' },
+ },
+ }), async (c) => {
+ const upload = uploads?.repository.findOwned(c.req.param('downloadId'), c.get('apiKeyId') ?? null)
+ if (!upload)
+ return c.json({ error: 'Transfer not found' }, 404)
+ const result = await controller.streamFile(upload.itemId, c.req.header('Range'))
+ if (!result)
+ return c.json({ error: 'File not found' }, 404)
+ // A HEAD request sends no body, so it serves nothing. Bookkeeping never fails
+ // a transfer: a database error is logged and the file still goes out.
+ if (result.type !== 'unsatisfiable' && c.req.method !== 'HEAD') {
+ try {
+ uploads!.repository.served(upload.id, result.type === 'partial'
+ ? result.start
+ : 0)
+ }
+ catch (err) {
+ logger.warn({ uploadId: upload.id, err }, 'Could not record a transfer request')
+ }
}
-
- return new Response(result.body, {
- headers: {
- 'Content-Type': 'application/octet-stream',
- 'Content-Length': String(result.size),
- 'Accept-Ranges': 'bytes',
- 'Content-Disposition': `attachment; filename="${result.filename}"`,
- ...uploadHeader,
- },
- })
+ return fileResponse(result)
})
- app.post('/uploads/:uploadId/receipt', describeRoute({
+ app.post('/downloads/:downloadId/receipt', describeRoute({
tags: ['Peer'],
- summary: 'Report a finished download',
- description: 'Sent by the downloading jack after a file from `/peer/items/:itemId/file` arrived, with the `X-Jack-Upload-Id` that response carried. Records how many bytes the request delivered and how long it took. Always answers 204, so a stale or unknown id is not an error worth retrying.',
+ summary: 'Complete a transfer',
+ description: 'Sent by the downloading jack once the whole file arrived. jack records the bytes it served and the time since the transfer was created. Always answers 204, so a stale or unknown id is not an error worth retrying. Added in protocol 0.3.0.',
security: [
{ 'X-Api-Key': [] },
],
responses: {
204: { description: 'Receipt taken (or ignored)' },
- 400: { description: 'Malformed receipt' },
},
- }), zValidator('json', UploadReceipt), (c) => {
- const uploadId = Number(c.req.param('uploadId'))
- const { bytes, durationMs } = c.req.valid('json')
- if (uploads && Number.isSafeInteger(uploadId))
- uploads.receipt(uploadId, c.get('apiKeyId') ?? null, bytes, durationMs)
+ }), (c) => {
+ uploads?.repository.complete(c.req.param('downloadId'), c.get('apiKeyId') ?? null)
return c.body(null, 204)
})
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index afa97f9..66ca634 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -3,11 +3,9 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base'
import type { ServerConnector } from '../../lib/servers/base'
import type { PeerConnector } from '../../lib/servers/peer'
import type { DownloadRecord, DownloadsRepository } from '../downloads/downloads.repository'
-import type { LiveProgressTracker } from '../downloads/live-progress'
import type { UploadRecord } from '../uploads/uploads.repository'
import type { UploadsService } from '../uploads/uploads.service'
import { DOWNLOAD_STATUSES, UPLOAD_STATUSES } from '../../database/schema'
-import { liveProgressFor } from '../downloads/live-progress'
// The management surface can expose richer detail than the public API: every
// connector here carries its URL-derived `id` (the management UI keys edit/delete
@@ -29,27 +27,23 @@ function baseConnector(c: ServerConnector) {
}
}
-export function enrichDownload(d: DownloadRecord, live?: LiveProgressTracker) {
+export function enrichDownload(d: DownloadRecord) {
// Prefer the negotiated expected size; fall back to the release-advertised size.
const totalBytes = d.expectedBytes ?? (d.releaseSize > 0
? d.releaseSize
: null)
- // A running transfer reports bytes about once a second in memory; the row is only
- // written every 10 s / 64 MiB, so prefer the live count while it's downloading.
- const current = d.status === 'downloading'
- ? live?.get(d.id)
- : null
- const downloadedBytes = Math.max(d.downloadedBytes, current?.downloadedBytes ?? 0)
const progress = totalBytes && totalBytes > 0
- ? Math.min(1, downloadedBytes / totalBytes)
+ ? Math.min(1, d.downloadedBytes / totalBytes)
+ : null
+ // Measured between progress writes (every 10 s / 64 MiB); only while running.
+ const speedBps = d.status === 'downloading'
+ ? d.speedBps
: null
- const speedBps = current?.speedBps ?? null
const etaSeconds = speedBps && speedBps > 0 && totalBytes
- ? Math.max(0, Math.round((totalBytes - downloadedBytes) / speedBps))
+ ? Math.max(0, Math.round((totalBytes - d.downloadedBytes) / speedBps))
: null
return {
...d,
- downloadedBytes,
totalBytes,
progress,
speedBps,
@@ -58,10 +52,9 @@ export function enrichDownload(d: DownloadRecord, live?: LiveProgressTracker) {
}
export function enrichUpload(u: UploadRecord) {
- // Files go out zero-copy, so only a receipt tells us how far it got. Progress is
- // through the whole file, so a resumed request starts where the range does.
- const progress = u.status === 'completed' && u.totalBytes > 0
- ? Math.min(1, (u.rangeStart + u.sentBytes) / u.totalBytes)
+ // Files go out zero-copy, so only a receipt says the file arrived.
+ const progress = u.status === 'completed'
+ ? 1
: null
// Average over the whole transfer, from the receipt; unknown without one.
const speedBps = u.durationMs && u.durationMs > 0
@@ -116,18 +109,13 @@ export class StatusController {
private enrichUpload = (u: UploadRecord) => enrichUpload(u)
- private get live(): LiveProgressTracker | undefined {
- return this.downloads && liveProgressFor(this.downloads)
- }
-
- private enrich = (d: DownloadRecord) => enrichDownload(d, this.live)
+ private enrich = (d: DownloadRecord) => enrichDownload(d)
subscribeOverview(subscriber: () => void): () => void {
return coalesce(subscriber, [
// Called through the object: ConnectorManager.subscribe reads `this`.
this.connectors.subscribe && (s => this.connectors.subscribe!(s)),
this.downloads && (s => this.downloads!.subscribe(s)),
- this.live && (s => this.live!.subscribe(s)),
this.uploads && (s => this.uploads!.subscribe(s)),
])
}
@@ -141,7 +129,6 @@ export class StatusController {
subscribeDownloads(subscriber: () => void): () => void {
return coalesce(subscriber, [
this.downloads && (s => this.downloads!.subscribe(s)),
- this.live && (s => this.live!.subscribe(s)),
])
}
diff --git a/apps/backend/src/modules/status/status.router.ts b/apps/backend/src/modules/status/status.router.ts
index 4284f21..62002b6 100644
--- a/apps/backend/src/modules/status/status.router.ts
+++ b/apps/backend/src/modules/status/status.router.ts
@@ -65,7 +65,7 @@ export function getStatusRouter(controller: StatusController) {
app.get('/uploads', describeRoute({
tags: ['Uploads'],
summary: 'List uploads',
- description: 'Files peers pulled or are pulling from this instance, newest first. One record per file request, so a resumed transfer adds a record that starts at its `rangeStart`.',
+ description: 'Files peers pulled or are pulling from this instance, newest first. One record per transfer, however often the peer resumed or retried it.',
security: [
{ 'X-Management-Key': [] },
],
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
index 02de750..752d41a 100644
--- a/apps/backend/src/modules/uploads/uploads.repository.ts
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -1,29 +1,35 @@
import type { AppDatabase } from '../../database/connection'
-import type { NewUploadRow, UploadRow, UploadStatus } from '../../database/schema'
-import { and, count, desc, eq, inArray, isNull, lt, sum } from 'drizzle-orm'
+import type { UploadRow, UploadStatus } from '../../database/schema'
+import { and, count, desc, eq, inArray, isNotNull, isNull, lt, sql, sum } from 'drizzle-orm'
import { UPLOAD_STATUSES, uploads } from '../../database/schema'
export type UploadRecord = UploadRow
-export interface CreateUploadInput {
+// A transfer can still be resumed and completed; a late receipt revives an expired one.
+const OPEN = ['uploading', 'unconfirmed'] as const
+
+export interface CreateTransferInput {
peerName: string
peerKeyId: number | null
itemId: string
filename: string
totalBytes: number
- rangeStart: number
- requestedBytes: number
+ clientTransferId: string
}
-// Statuses an upload can still leave: a receipt or a disconnect may yet arrive.
-const OPEN = ['uploading', 'unconfirmed'] as const
-
function nowIso() {
return new Date().toISOString()
}
+/** Rows a key owns: only its own api key (null for the master key) may use a transfer. */
+function ownedBy(peerKeyId: number | null) {
+ return peerKeyId === null
+ ? isNull(uploads.peerKeyId)
+ : eq(uploads.peerKeyId, peerKeyId)
+}
+
/**
- * Files peers pull from this instance, one row per request. Rows are kept after
+ * Files peers pull from this instance, one row per transfer. Rows are kept after
* they finish so the list doubles as a history.
*/
export class UploadsRepository {
@@ -47,25 +53,43 @@ export class UploadsRepository {
}
}
- /** `uploading` when the peer promised a receipt, `unconfirmed` when it can't send one. */
- create(input: CreateUploadInput, status: 'uploading' | 'unconfirmed'): UploadRecord {
+ /**
+ * Creates the transfer, or returns the one this key already created with the
+ * same client transfer id. `created` is false for the latter.
+ */
+ create(input: CreateTransferInput): { upload: UploadRecord, created: boolean } {
+ const { clientTransferId, ...row } = input
+ const idempotencyKey = `${input.peerKeyId ?? 'master'}:${clientTransferId}`
const timestamp = nowIso()
- const values: NewUploadRow = {
- ...input,
- sentBytes: 0,
- status,
- startedAt: timestamp,
- updatedAt: timestamp,
+ const inserted = this.db.insert(uploads)
+ .values({
+ ...row,
+ transferId: crypto.randomUUID(),
+ idempotencyKey,
+ status: 'uploading',
+ startedAt: timestamp,
+ updatedAt: timestamp,
+ })
+ .onConflictDoNothing({ target: uploads.idempotencyKey })
+ .returning()
+ .get()
+ if (inserted) {
+ this.notifyChanged()
+ return { upload: inserted, created: true }
}
- const row = this.db.insert(uploads).values(values).returning().get()
- this.notifyChanged()
- return row
+ const existing = this.db.select().from(uploads).where(eq(uploads.idempotencyKey, idempotencyKey)).get()!
+ return { upload: existing, created: false }
}
get(id: number): UploadRecord | null {
return this.db.select().from(uploads).where(eq(uploads.id, id)).get() ?? null
}
+ /** The transfer behind a `downloadId`, if this key owns it. */
+ findOwned(transferId: string, peerKeyId: number | null): UploadRecord | null {
+ return this.db.select().from(uploads).where(and(eq(uploads.transferId, transferId), ownedBy(peerKeyId))).get() ?? null
+ }
+
/** Totals for the dashboard, counted in SQLite so the history is never loaded. */
summary(): { byStatus: Record, total: number, bytesSent: number } {
const rows = this.db.select({
@@ -87,7 +111,7 @@ export class UploadsRepository {
return { byStatus, total, bytesSent }
}
- /** Uploads still being sent, newest first. */
+ /** Transfers still open, newest first. */
listActive(): UploadRecord[] {
return this.db.select().from(uploads).where(eq(uploads.status, 'uploading')).orderBy(desc(uploads.startedAt), desc(uploads.id)).all()
}
@@ -97,45 +121,34 @@ export class UploadsRepository {
return this.db.select().from(uploads).orderBy(desc(uploads.startedAt), desc(uploads.id)).all()
}
- /** The peer disconnected before the body was sent. No-op once the row has ended. */
- markAborted(id: number, error: string): void {
- const timestamp = nowIso()
- const changed = this.db.update(uploads)
+ /** A request for the file starting at byte `start`; keeps the lowest one seen. */
+ served(id: number, start: number): void {
+ this.db.update(uploads)
.set({
- status: 'aborted',
- error,
- updatedAt: timestamp,
- finishedAt: timestamp,
+ rangeStart: sql`min(coalesce(${uploads.rangeStart}, ${start}), ${start})`,
+ updatedAt: nowIso(),
})
.where(and(eq(uploads.id, id), inArray(uploads.status, [...OPEN])))
- .returning({ id: uploads.id })
- .all()
- if (changed.length > 0)
- this.notifyChanged()
+ .run()
+ this.notifyChanged()
}
/**
- * The downloading peer's receipt: it got `sentBytes` in `durationMs`. Only the
- * key the row belongs to (null for the master key) can close it, only while it
- * is still open, and only with exactly the bytes the request asked for: a
- * receipt means the whole response arrived, and anything else would let a peer
+ * The downloading peer's receipt: the whole file arrived. Only the owning key
+ * can close a transfer, only while it is open, and only once the file was
+ * requested. The bytes and time come from what jack served, so a peer can't
* skew the totals. Returns whether the row was updated.
*/
- complete(id: number, peerKeyId: number | null, sentBytes: number, durationMs: number): boolean {
- const timestamp = nowIso()
- const owner = peerKeyId === null
- ? isNull(uploads.peerKeyId)
- : eq(uploads.peerKeyId, peerKeyId)
+ complete(transferId: string, peerKeyId: number | null, now = Date.now()): boolean {
const changed = this.db.update(uploads)
.set({
status: 'completed',
- sentBytes,
- durationMs,
- error: null,
- updatedAt: timestamp,
- finishedAt: timestamp,
+ sentBytes: sql`${uploads.totalBytes} - ${uploads.rangeStart}`,
+ durationMs: sql`max(0, ${now} - cast(round(unixepoch(${uploads.startedAt}, 'subsec') * 1000) as integer))`,
+ updatedAt: new Date(now).toISOString(),
+ finishedAt: new Date(now).toISOString(),
})
- .where(and(eq(uploads.id, id), owner, inArray(uploads.status, [...OPEN]), eq(uploads.requestedBytes, sentBytes)))
+ .where(and(eq(uploads.transferId, transferId), ownedBy(peerKeyId), inArray(uploads.status, [...OPEN]), isNotNull(uploads.rangeStart)))
.returning({ id: uploads.id })
.all()
if (changed.length > 0)
@@ -143,7 +156,7 @@ export class UploadsRepository {
return changed.length > 0
}
- /** Rows still waiting for a receipt that started before `before`: it isn't coming. */
+ /** Transfers still open that started before `before`: their receipt isn't coming. */
expireUnconfirmed(before: string): number {
const rows = this.db.update(uploads)
.set({
@@ -157,22 +170,4 @@ export class UploadsRepository {
this.notifyChanged()
return rows.length
}
-
- /** Rows still `uploading` from a previous run lost their connection with it. */
- reconcileInterrupted(): number {
- const timestamp = nowIso()
- const rows = this.db.update(uploads)
- .set({
- status: 'aborted',
- error: 'interrupted by a jack restart',
- updatedAt: timestamp,
- finishedAt: timestamp,
- })
- .where(eq(uploads.status, 'uploading'))
- .returning({ id: uploads.id })
- .all()
- if (rows.length > 0)
- this.notifyChanged()
- return rows.length
- }
}
diff --git a/apps/backend/src/modules/uploads/uploads.service.ts b/apps/backend/src/modules/uploads/uploads.service.ts
index 7a11bb1..0aefbca 100644
--- a/apps/backend/src/modules/uploads/uploads.service.ts
+++ b/apps/backend/src/modules/uploads/uploads.service.ts
@@ -1,59 +1,19 @@
-import type { CreateUploadInput, UploadRecord, UploadsRepository } from './uploads.repository'
+import type { UploadRecord, UploadsRepository } from './uploads.repository'
import { logger } from '../../logger'
// A receipt that hasn't come after this long won't: the row goes `unconfirmed`.
const RECEIPT_WAIT_MS = 48 * 60 * 60 * 1000
/**
- * Records files peers pull from `/peer/items/:id/file`. The file itself is sent
- * zero-copy, untouched; this only keeps the row. A disconnect marks it aborted,
- * and the downloading jack's receipt (`/peer/uploads/:id/receipt`) completes it
- * with the bytes and time the transfer took.
+ * Transfers peers pull from this instance. A peer creates one with
+ * `POST /peer/items/:itemId/downloads`, streams it from
+ * `/peer/downloads/:downloadId/file` as many times as it needs to resume, and
+ * completes it with `/peer/downloads/:downloadId/receipt`. The file itself is
+ * sent zero-copy, untouched; this only keeps the row.
*/
export class UploadsService {
constructor(readonly repository: UploadsRepository) {}
- /**
- * Starts a row for this request and returns its id, for the `X-Jack-Upload-Id`
- * response header. `expectsReceipt` is whether the peer said it will report back.
- *
- * Returns undefined when the row can't be written (e.g. the database disk is
- * full): the file is still served, just untracked. Bookkeeping never fails a
- * transfer.
- */
- start(input: CreateUploadInput, expectsReceipt: boolean, signal?: AbortSignal): number | undefined {
- try {
- const upload = this.repository.create(input, expectsReceipt ? 'uploading' : 'unconfirmed')
- // Runs from an event listener, outside this try: a database error there must
- // be logged, not thrown into the server.
- const abort = () => {
- try {
- this.repository.markAborted(upload.id, 'peer disconnected')
- }
- catch (err) {
- logger.warn({ uploadId: upload.id, err }, 'Could not mark an upload aborted')
- }
- }
- // The peer may have left while jack was looking up the file, and a listener
- // added now would never fire.
- if (signal?.aborted)
- abort()
- // Bun aborts the request signal only when the peer disconnects before the
- // whole body went out; a finished response never fires it.
- else
- signal?.addEventListener('abort', abort, { once: true })
- return upload.id
- }
- catch (err) {
- logger.warn({ itemId: input.itemId, peer: input.peerName, err }, 'Could not record an upload; serving it untracked')
- return undefined
- }
- }
-
- receipt(id: number, peerKeyId: number | null, sentBytes: number, durationMs: number): boolean {
- return this.repository.complete(id, peerKeyId, sentBytes, durationMs)
- }
-
/** Called from a timer, so it never throws: a failure is logged and retried next time. */
expireUnconfirmed(now = Date.now()): number {
try {
diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue
index 691bbf3..a32e589 100644
--- a/apps/ui/app/pages/index.vue
+++ b/apps/ui/app/pages/index.vue
@@ -157,9 +157,9 @@ function serverRole(server: { source: boolean, destination: boolean }) {
to {{ u.peerName }}
-
+
- Sending {{ formatBytes(u.requestedBytes) }} of {{ formatBytes(u.totalBytes) }} · started {{ formatAgo(u.startedAt) }} ago
+ Sending {{ formatBytes(u.totalBytes) }} · started {{ formatAgo(u.startedAt) }} ago
diff --git a/apps/ui/app/pages/uploads.vue b/apps/ui/app/pages/uploads.vue
index 8bd9315..83e20fd 100644
--- a/apps/ui/app/pages/uploads.vue
+++ b/apps/ui/app/pages/uploads.vue
@@ -16,7 +16,6 @@ const { connected } = useManagementStream<{ uploads: UploadItem[] }>('uploads/st
const statusBadge: Record = {
uploading: { color: 'primary', label: 'Uploading' },
completed: { color: 'success', label: 'Completed' },
- aborted: { color: 'warning', label: 'Aborted' },
unconfirmed: { color: 'neutral', label: 'Unconfirmed' },
}
@@ -40,7 +39,6 @@ const counts = computed(() => {
all: all.length,
uploading: all.filter(u => u.status === 'uploading').length,
completed: all.filter(u => u.status === 'completed').length,
- aborted: all.filter(u => u.status === 'aborted').length,
unconfirmed: all.filter(u => u.status === 'unconfirmed').length,
}
})
@@ -48,7 +46,6 @@ const chips: { key: StatusFilter, label: string, color: BadgeProps['color'] }[]
{ key: 'all', label: 'All', color: 'neutral' },
{ key: 'uploading', label: 'Uploading', color: 'primary' },
{ key: 'completed', label: 'Completed', color: 'success' },
- { key: 'aborted', label: 'Aborted', color: 'warning' },
{ key: 'unconfirmed', label: 'Unconfirmed', color: 'neutral' },
]
@@ -234,12 +231,9 @@ const columns: TableColumn[] = [
{{ row.original.filename }}
-
+
resumed at {{ formatBytes(row.original.rangeStart) }}
-
- {{ row.original.error }}
-
@@ -298,9 +292,6 @@ const columns: TableColumn[] = [
{{ u.filename }}
-
- {{ u.error }}
-
@@ -310,7 +301,7 @@ const columns: TableColumn[] = [
{{ u.peerName }}
- {{ formatBytes(u.requestedBytes) }} of {{ formatBytes(u.totalBytes) }} · {{ formatAgo(u.startedAt) }} ago
+ {{ formatBytes(u.totalBytes) }} · {{ formatAgo(u.startedAt) }} ago
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts
index ea93759..c596c97 100644
--- a/apps/ui/app/types/management.ts
+++ b/apps/ui/app/types/management.ts
@@ -86,8 +86,8 @@ export interface DownloadItem {
expectedBytesMismatch: boolean
}
-// A file a peer pulls from this instance. One per file request: a resumed
-// transfer is a new upload starting at `rangeStart`.
+// A file a peer pulls from this instance. One per transfer, however often the
+// peer resumed or retried it.
export interface UploadItem {
id: number
// The API key the peer used; 'master' for the master key.
@@ -96,20 +96,20 @@ export interface UploadItem {
filename: string
// The whole file's size on disk.
totalBytes: number
- rangeStart: number
- requestedBytes: number
- // From the downloading jack's receipt; 0 until it arrives.
+ // Lowest byte requested: past 0 when the peer already had part of the file.
+ // Null until the file is first requested.
+ rangeStart: number | null
+ // Bytes this transfer delivered; 0 until the receipt.
sentBytes: number
- // Transfer time from the receipt; null without one.
+ // Creation to receipt; null without one.
durationMs: number | null
- // Through the whole file, counting the range start. Null until completed.
+ // 1 once completed, null before.
progress: number | null
- // Average over the whole transfer, from the receipt. Null without one.
+ // Average over the whole transfer. Null without a receipt.
speedBps: number | null
- // Files go out zero-copy: `completed` needs the peer's receipt, and a peer that
- // sends none (an older jack, or a lost receipt) leaves it `unconfirmed`.
- status: 'uploading' | 'completed' | 'aborted' | 'unconfirmed'
- error: string | null
+ // Files go out zero-copy: `completed` needs the peer's receipt, and no receipt
+ // within 48 hours leaves it `unconfirmed`.
+ status: 'uploading' | 'completed' | 'unconfirmed'
startedAt: string
updatedAt: string
finishedAt: string | null
diff --git a/website/guide/management-ui.md b/website/guide/management-ui.md
index 4b7122d..dc99b64 100644
--- a/website/guide/management-ui.md
+++ b/website/guide/management-ui.md
@@ -45,23 +45,23 @@ The ***Uploads*** page lists them newest first, read-only, with:
- **Sent**: bytes the peer received, with the average speed and how long it
took.
- **Status**:
- - `uploading` while the file is being sent.
+ - `uploading` while the transfer is open, including while the peer waits to
+ retry or resume it.
- `completed` once the peer confirmed it got the file.
- - `aborted` when the peer disconnected or jack restarted mid-transfer.
- - `unconfirmed` when the peer never confirmed. Its size and speed stay
- unknown.
-
-jack sends files straight from disk without reading them itself, which keeps
-uploads cheap but means it can't see a transfer finish. The downloading jack
-reports back instead: once the file arrives, it sends a receipt with the bytes
-it got and the time it took. Peers on versions without receipts, or a receipt
-that gets lost, leave the upload `unconfirmed`, and an upload still waiting for
-its receipt after 48 hours becomes `unconfirmed` too. There is no live speed
-while a file is being sent.
-
-Each file request is its own row. A peer that resumes a transfer sends a
-`Range` request, so the resume shows as a new row whose progress starts where
-the range does. The ***Overview*** page shows the uploads running now next to
+ - `unconfirmed` when no confirmation came within 48 hours. Its size and speed
+ stay unknown, and a confirmation that still arrives later completes it.
+
+Each row is one transfer. The downloading jack starts it before asking for the
+file, then resumes and retries it as often as it needs, all on the same row. A
+dropped connection leaves it open. jack sends files straight from disk without
+reading them itself, which keeps uploads low-cost but means it can't see a
+transfer finish, so the downloading jack sends a receipt once the file arrives.
+The bytes sent count from the lowest byte the peer asked for, and the speed
+runs from the transfer's start to its receipt, retries included. There is no
+live speed while a file is being sent.
+
+Peers running an older jack don't start transfers, so their downloads from you
+aren't listed. The ***Overview*** page shows the uploads running now next to
active downloads.
## Settings -> Downloads
@@ -149,7 +149,7 @@ The streams are plain `GET` endpoints on the management API:
| --- | --- |
| `/overview/stream` | Connector overview + download and upload state |
| `/downloads/stream` | Every persisted download change |
-| `/uploads/stream` | Every upload change, about once a second while files stream |
+| `/uploads/stream` | Every upload change |
| `/config/stream` | Peer and server config changes |
Each sends its current snapshot immediately on connect, then a full snapshot per
From ffca03eaabd4cfb4646fbe66f1be77f03760932c Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Tue, 10 Nov 2026 01:02:56 +0100
Subject: [PATCH 16/18] fix: build the transfer URL locally, clear stale
download speeds, recount uploads of replaced files
---
.../src/__tests__/peer-download.test.ts | 3 +-
.../src/__tests__/upload-tracking.test.ts | 10 ++++--
.../src/__tests__/uploads-repository.test.ts | 32 +++++++++++++++----
apps/backend/src/lib/servers/peer.ts | 7 ++--
.../modules/downloads/downloads.repository.ts | 5 +++
.../modules/downloads/downloads.service.ts | 2 ++
apps/backend/src/modules/peer/peer.router.ts | 7 +++-
.../src/modules/status/status.controller.ts | 11 +++++--
.../src/modules/uploads/uploads.repository.ts | 12 +++++--
9 files changed, 68 insertions(+), 21 deletions(-)
diff --git a/apps/backend/src/__tests__/peer-download.test.ts b/apps/backend/src/__tests__/peer-download.test.ts
index ec0b5fe..dc21956 100644
--- a/apps/backend/src/__tests__/peer-download.test.ts
+++ b/apps/backend/src/__tests__/peer-download.test.ts
@@ -160,7 +160,8 @@ describe('PeerConnector.downloadFile', () => {
downloadId: 'abc',
filename: 'Movie.mkv',
totalBytes: 4,
- url: '/peer/downloads/abc/file',
+ // Ignored: the file URL is built from the downloadId on the peer's own host.
+ url: 'http://internal.test/admin',
}, { status: 201 })
}),
http.get(`${PEER_JACK_URL}/peer/downloads/:downloadId/file`, ({ params }) => {
diff --git a/apps/backend/src/__tests__/upload-tracking.test.ts b/apps/backend/src/__tests__/upload-tracking.test.ts
index b0b1be9..3d392fc 100644
--- a/apps/backend/src/__tests__/upload-tracking.test.ts
+++ b/apps/backend/src/__tests__/upload-tracking.test.ts
@@ -140,14 +140,18 @@ describe('peer transfers', () => {
})
})
-test('download speed and ETA come from the row, only while it runs', () => {
+test('download speed and ETA come from the row, only while bytes still arrive', () => {
+ const now = Date.parse('2026-11-08T12:00:00Z')
const row = {
status: 'downloading',
expectedBytes: 10_000,
releaseSize: 10_000,
downloadedBytes: 2000,
speedBps: 2000,
+ updatedAt: '2026-11-08T11:59:50Z',
} as Parameters[0]
- expect(enrichDownload(row)).toMatchObject({ progress: 0.2, speedBps: 2000, etaSeconds: 4 })
- expect(enrichDownload({ ...row, status: 'import_queued' })).toMatchObject({ speedBps: null, etaSeconds: null })
+ expect(enrichDownload(row, now)).toMatchObject({ progress: 0.2, speedBps: 2000, etaSeconds: 4 })
+ expect(enrichDownload({ ...row, status: 'import_queued' }, now)).toMatchObject({ speedBps: null, etaSeconds: null })
+ // Nothing written for a minute: stalled, so no speed.
+ expect(enrichDownload({ ...row, updatedAt: '2026-11-08T11:59:00Z' }, now)).toMatchObject({ speedBps: null, etaSeconds: null })
})
diff --git a/apps/backend/src/__tests__/uploads-repository.test.ts b/apps/backend/src/__tests__/uploads-repository.test.ts
index eaaa95b..054b152 100644
--- a/apps/backend/src/__tests__/uploads-repository.test.ts
+++ b/apps/backend/src/__tests__/uploads-repository.test.ts
@@ -14,6 +14,8 @@ const input = {
totalBytes: 100,
clientTransferId: 'download-1',
}
+// The file as served, unchanged since the transfer started.
+const file = { filename: 'Movie.mkv', totalBytes: 100 }
describe('UploadsRepository', () => {
test('creates an open transfer with nothing served yet', () => {
@@ -57,9 +59,9 @@ describe('UploadsRepository', () => {
// Never requested: nothing was sent, so there is nothing to complete.
expect(repo.complete(transferId, 7, now)).toBe(false)
- repo.served(id, 60)
- repo.served(id, 40)
- repo.served(id, 70)
+ repo.served(id, 60, file)
+ repo.served(id, 40, file)
+ repo.served(id, 70, file)
// Another key, even one with the same name, can't close it; nor can the master key.
expect(repo.complete(transferId, 8, now)).toBe(false)
expect(repo.complete(transferId, null, now)).toBe(false)
@@ -72,14 +74,30 @@ describe('UploadsRepository', () => {
})
// Closed rows stay closed, and serving them again changes nothing.
expect(repo.complete(transferId, 7, now)).toBe(false)
- repo.served(id, 0)
+ repo.served(id, 0, file)
expect(repo.get(id)?.rangeStart).toBe(40)
})
+ test('a file replaced mid-transfer counts from the request that served the new one', () => {
+ const repo = makeRepo()
+ const { id, transferId } = repo.create(input).upload
+ repo.served(id, 40, file)
+ // *arr swapped in a 250-byte file; the peer's resume fails and it starts over.
+ repo.served(id, 0, { filename: 'Movie.2160p.mkv', totalBytes: 250 })
+ repo.served(id, 100, { filename: 'Movie.2160p.mkv', totalBytes: 250 })
+ expect(repo.complete(transferId, 7)).toBe(true)
+ expect(repo.get(id)).toMatchObject({
+ filename: 'Movie.2160p.mkv',
+ totalBytes: 250,
+ rangeStart: 0,
+ sentBytes: 250,
+ })
+ })
+
test('expires transfers that waited too long; a late receipt still completes one', () => {
const repo = makeRepo()
const { id, transferId } = repo.create(input).upload
- repo.served(id, 0)
+ repo.served(id, 0, file)
expect(repo.expireUnconfirmed('2000-01-01T00:00:00Z')).toBe(0)
expect(repo.expireUnconfirmed('9999-01-01T00:00:00Z')).toBe(1)
expect(repo.get(id)?.status).toBe('unconfirmed')
@@ -91,7 +109,7 @@ describe('UploadsRepository', () => {
const repo = makeRepo()
repo.create({ ...input, clientTransferId: 'a' })
const done = repo.create({ ...input, clientTransferId: 'b' }).upload
- repo.served(done.id, 40)
+ repo.served(done.id, 40, file)
repo.complete(done.transferId, 7)
repo.create({ ...input, clientTransferId: 'c' })
// Expires a and c; d is the only one still open.
@@ -123,7 +141,7 @@ describe('UploadsRepository', () => {
repo.subscribe(() => calls++)
const { id, transferId } = repo.create(input).upload
repo.create(input)
- repo.served(id, 0)
+ repo.served(id, 0, file)
repo.complete(transferId, 7)
expect(calls).toBe(3)
})
diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts
index fe8ccb2..7d5d4af 100644
--- a/apps/backend/src/lib/servers/peer.ts
+++ b/apps/backend/src/lib/servers/peer.ts
@@ -25,8 +25,7 @@ const DOWNLOAD_PROGRESS_BYTES = 64 * 1024 * 1024
const CONTENT_RANGE_PATTERN = /^bytes (\d+)-(\d+)\/(\d+)$/
const PeerTransfer = z.object({
- downloadId: z.string(),
- url: z.string(),
+ downloadId: z.string().min(1),
})
export type PeerDownloadProgressEvent
@@ -287,8 +286,10 @@ export class PeerConnector extends ServerConnector {
AbortSignal.timeout(30_000),
]),
})
+ // Built here rather than taken from the response, so a peer can't point the
+ // request (and the api key it carries) at another host.
return {
- url: new URL(transfer.url, this.url),
+ url: new URL(`/peer/downloads/${encodeURIComponent(transfer.downloadId)}/file`, this.url),
downloadId: transfer.downloadId,
}
}
diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts
index 3fab280..f0e9dbf 100644
--- a/apps/backend/src/modules/downloads/downloads.repository.ts
+++ b/apps/backend/src/modules/downloads/downloads.repository.ts
@@ -228,6 +228,11 @@ export class DownloadsRepository {
this.notifyChanged()
}
+ clearSpeed(id: number): void {
+ this.db.update(downloads).set({ speedBps: null }).where(eq(downloads.id, id)).run()
+ this.notifyChanged()
+ }
+
// Download finished and the file is in completedPath, handed to *arr to import.
// `completedAt` records when the transfer finished (what the qB API reports as
// completion_on); the row stays here until *arr's import is detected.
diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts
index 7ba138a..2a2a7df 100644
--- a/apps/backend/src/modules/downloads/downloads.service.ts
+++ b/apps/backend/src/modules/downloads/downloads.service.ts
@@ -447,6 +447,8 @@ export class DownloadsService {
delayMs,
...transferErrorLogFields(error),
}, 'Retrying peer download after transient failure')
+ // Nothing arrives while it waits to retry.
+ repo?.clearSpeed(record.id)
},
signal,
})
diff --git a/apps/backend/src/modules/peer/peer.router.ts b/apps/backend/src/modules/peer/peer.router.ts
index 2d4db21..dcf3f3e 100644
--- a/apps/backend/src/modules/peer/peer.router.ts
+++ b/apps/backend/src/modules/peer/peer.router.ts
@@ -222,7 +222,12 @@ export function getPeerRouter(controller: PeerController, uploads?: UploadsServi
try {
uploads!.repository.served(upload.id, result.type === 'partial'
? result.start
- : 0)
+ : 0, {
+ filename: result.filename,
+ totalBytes: result.type === 'partial'
+ ? result.totalSize
+ : result.size,
+ })
}
catch (err) {
logger.warn({ uploadId: upload.id, err }, 'Could not record a transfer request')
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 66ca634..7dcad61 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -27,7 +27,11 @@ function baseConnector(c: ServerConnector) {
}
}
-export function enrichDownload(d: DownloadRecord) {
+// Progress is written at least every 10 s while bytes arrive; a row quiet for
+// longer than this is stalled, so its last speed no longer holds.
+const SPEED_STALE_MS = 30_000
+
+export function enrichDownload(d: DownloadRecord, now = Date.now()) {
// Prefer the negotiated expected size; fall back to the release-advertised size.
const totalBytes = d.expectedBytes ?? (d.releaseSize > 0
? d.releaseSize
@@ -35,8 +39,9 @@ export function enrichDownload(d: DownloadRecord) {
const progress = totalBytes && totalBytes > 0
? Math.min(1, d.downloadedBytes / totalBytes)
: null
- // Measured between progress writes (every 10 s / 64 MiB); only while running.
- const speedBps = d.status === 'downloading'
+ // Measured between progress writes (every 10 s / 64 MiB); only while bytes
+ // still arrive. A failed attempt clears it before its retry wait.
+ const speedBps = d.status === 'downloading' && now - Date.parse(d.updatedAt) <= SPEED_STALE_MS
? d.speedBps
: null
const etaSeconds = speedBps && speedBps > 0 && totalBytes
diff --git a/apps/backend/src/modules/uploads/uploads.repository.ts b/apps/backend/src/modules/uploads/uploads.repository.ts
index 752d41a..9af3d16 100644
--- a/apps/backend/src/modules/uploads/uploads.repository.ts
+++ b/apps/backend/src/modules/uploads/uploads.repository.ts
@@ -121,11 +121,17 @@ export class UploadsRepository {
return this.db.select().from(uploads).orderBy(desc(uploads.startedAt), desc(uploads.id)).all()
}
- /** A request for the file starting at byte `start`; keeps the lowest one seen. */
- served(id: number, start: number): void {
+ /**
+ * A request for the file as it is now on disk, starting at byte `start`. Keeps
+ * the lowest start seen; a file *arr replaced since (another size) starts over
+ * from this request, since the peer has to download the new file anyway.
+ */
+ served(id: number, start: number, file: { filename: string, totalBytes: number }): void {
this.db.update(uploads)
.set({
- rangeStart: sql`min(coalesce(${uploads.rangeStart}, ${start}), ${start})`,
+ rangeStart: sql`case when ${uploads.totalBytes} = ${file.totalBytes} then min(coalesce(${uploads.rangeStart}, ${start}), ${start}) else ${start} end`,
+ totalBytes: file.totalBytes,
+ filename: file.filename,
updatedAt: nowIso(),
})
.where(and(eq(uploads.id, id), inArray(uploads.status, [...OPEN])))
From 55f3b035717cd5ccf615f489ea568679b7a40663 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Tue, 10 Nov 2026 01:31:48 +0100
Subject: [PATCH 17/18] fix: keep the original uploads migration and convert
the table in a new one
---
apps/backend/drizzle/0014_uploads.sql | 13 +-
.../backend/drizzle/0015_upload_transfers.sql | 29 +
apps/backend/drizzle/meta/0014_snapshot.json | 56 +-
apps/backend/drizzle/meta/0015_snapshot.json | 1081 +++++++++++++++++
apps/backend/drizzle/meta/_journal.json | 9 +-
5 files changed, 1141 insertions(+), 47 deletions(-)
create mode 100644 apps/backend/drizzle/0015_upload_transfers.sql
create mode 100644 apps/backend/drizzle/meta/0015_snapshot.json
diff --git a/apps/backend/drizzle/0014_uploads.sql b/apps/backend/drizzle/0014_uploads.sql
index 453b3a0..7887b20 100644
--- a/apps/backend/drizzle/0014_uploads.sql
+++ b/apps/backend/drizzle/0014_uploads.sql
@@ -1,24 +1,21 @@
CREATE TABLE `uploads` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
- `transfer_id` text NOT NULL,
- `idempotency_key` text NOT NULL,
`peer_name` text NOT NULL,
`peer_key_id` integer,
`item_id` text NOT NULL,
`filename` text NOT NULL,
`total_bytes` integer NOT NULL,
- `range_start` integer,
+ `range_start` integer DEFAULT 0 NOT NULL,
+ `requested_bytes` integer NOT NULL,
`sent_bytes` integer DEFAULT 0 NOT NULL,
`duration_ms` integer,
`status` text NOT NULL,
+ `error` text,
`started_at` text NOT NULL,
`updated_at` text NOT NULL,
`finished_at` text,
- CONSTRAINT "uploads_status_check" CHECK("uploads"."status" in ('uploading', 'completed', 'unconfirmed'))
+ CONSTRAINT "uploads_status_check" CHECK("uploads"."status" in ('uploading', 'completed', 'aborted', 'unconfirmed'))
);
--> statement-breakpoint
-CREATE UNIQUE INDEX `uploads_transfer_id_unique` ON `uploads` (`transfer_id`);--> statement-breakpoint
-CREATE UNIQUE INDEX `uploads_idempotency_key_unique` ON `uploads` (`idempotency_key`);--> statement-breakpoint
CREATE INDEX `uploads_status_idx` ON `uploads` (`status`);--> statement-breakpoint
-CREATE INDEX `uploads_started_at_idx` ON `uploads` (`started_at`);--> statement-breakpoint
-ALTER TABLE `downloads` ADD `speed_bps` integer;
\ No newline at end of file
+CREATE INDEX `uploads_started_at_idx` ON `uploads` (`started_at`);
\ No newline at end of file
diff --git a/apps/backend/drizzle/0015_upload_transfers.sql b/apps/backend/drizzle/0015_upload_transfers.sql
new file mode 100644
index 0000000..1393099
--- /dev/null
+++ b/apps/backend/drizzle/0015_upload_transfers.sql
@@ -0,0 +1,29 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_uploads` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `transfer_id` text NOT NULL,
+ `idempotency_key` text NOT NULL,
+ `peer_name` text NOT NULL,
+ `peer_key_id` integer,
+ `item_id` text NOT NULL,
+ `filename` text NOT NULL,
+ `total_bytes` integer NOT NULL,
+ `range_start` integer,
+ `sent_bytes` integer DEFAULT 0 NOT NULL,
+ `duration_ms` integer,
+ `status` text NOT NULL,
+ `started_at` text NOT NULL,
+ `updated_at` text NOT NULL,
+ `finished_at` text,
+ CONSTRAINT "uploads_status_check" CHECK("__new_uploads"."status" in ('uploading', 'completed', 'unconfirmed'))
+);
+--> statement-breakpoint
+INSERT INTO `__new_uploads`("id", "transfer_id", "idempotency_key", "peer_name", "peer_key_id", "item_id", "filename", "total_bytes", "range_start", "sent_bytes", "duration_ms", "status", "started_at", "updated_at", "finished_at") SELECT "id", lower(hex(randomblob(16))), 'legacy:' || "id", "peer_name", "peer_key_id", "item_id", "filename", "total_bytes", "range_start", "sent_bytes", "duration_ms", CASE WHEN "status" = 'aborted' THEN 'unconfirmed' ELSE "status" END, "started_at", "updated_at", "finished_at" FROM `uploads`;--> statement-breakpoint
+DROP TABLE `uploads`;--> statement-breakpoint
+ALTER TABLE `__new_uploads` RENAME TO `uploads`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE UNIQUE INDEX `uploads_transfer_id_unique` ON `uploads` (`transfer_id`);--> statement-breakpoint
+CREATE UNIQUE INDEX `uploads_idempotency_key_unique` ON `uploads` (`idempotency_key`);--> statement-breakpoint
+CREATE INDEX `uploads_status_idx` ON `uploads` (`status`);--> statement-breakpoint
+CREATE INDEX `uploads_started_at_idx` ON `uploads` (`started_at`);--> statement-breakpoint
+ALTER TABLE `downloads` ADD `speed_bps` integer;
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/0014_snapshot.json b/apps/backend/drizzle/meta/0014_snapshot.json
index 776e2b1..5ca2f7c 100644
--- a/apps/backend/drizzle/meta/0014_snapshot.json
+++ b/apps/backend/drizzle/meta/0014_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "f1dbe784-1ac2-4f13-84a0-08e7a930ded3",
+ "id": "c9f970ae-d2a3-41bd-b206-35a480a193bf",
"prevId": "7a0a25df-a2e9-4f9a-b477-42d28d9a01fc",
"tables": {
"api_keys": {
@@ -174,13 +174,6 @@
"autoincrement": false,
"default": 0
},
- "speed_bps": {
- "name": "speed_bps",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
"attempts": {
"name": "attempts",
"type": "integer",
@@ -927,20 +920,6 @@
"notNull": true,
"autoincrement": true
},
- "transfer_id": {
- "name": "transfer_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "idempotency_key": {
- "name": "idempotency_key",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
"peer_name": {
"name": "peer_name",
"type": "text",
@@ -980,7 +959,15 @@
"name": "range_start",
"type": "integer",
"primaryKey": false,
- "notNull": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "requested_bytes": {
+ "name": "requested_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
"autoincrement": false
},
"sent_bytes": {
@@ -1005,6 +992,13 @@
"notNull": true,
"autoincrement": false
},
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"started_at": {
"name": "started_at",
"type": "text",
@@ -1028,20 +1022,6 @@
}
},
"indexes": {
- "uploads_transfer_id_unique": {
- "name": "uploads_transfer_id_unique",
- "columns": [
- "transfer_id"
- ],
- "isUnique": true
- },
- "uploads_idempotency_key_unique": {
- "name": "uploads_idempotency_key_unique",
- "columns": [
- "idempotency_key"
- ],
- "isUnique": true
- },
"uploads_status_idx": {
"name": "uploads_status_idx",
"columns": [
@@ -1063,7 +1043,7 @@
"checkConstraints": {
"uploads_status_check": {
"name": "uploads_status_check",
- "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'unconfirmed')"
+ "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'aborted', 'unconfirmed')"
}
}
}
diff --git a/apps/backend/drizzle/meta/0015_snapshot.json b/apps/backend/drizzle/meta/0015_snapshot.json
new file mode 100644
index 0000000..8f4f4b3
--- /dev/null
+++ b/apps/backend/drizzle/meta/0015_snapshot.json
@@ -0,0 +1,1081 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "14e99ced-c742-4437-9940-60a0a5a6863a",
+ "prevId": "c9f970ae-d2a3-41bd-b206-35a480a193bf",
+ "tables": {
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "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
+ },
+ "speed_bps": {
+ "name": "speed_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "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
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_details": {
+ "name": "error_details",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_operation": {
+ "name": "last_operation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'transfer'"
+ },
+ "operation_failed": {
+ "name": "operation_failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "qb_category": {
+ "name": "qb_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_source_server": {
+ "name": "qb_source_server",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_server_id": {
+ "name": "source_server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "import_mode": {
+ "name": "import_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "import_target": {
+ "name": "import_target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "manual_import_command_id": {
+ "name": "manual_import_command_id",
+ "type": "integer",
+ "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', 'import_queued', 'imported', 'failed')"
+ },
+ "downloads_expected_bytes_source_check": {
+ "name": "downloads_expected_bytes_source_check",
+ "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" in ('content_length', 'content_range', 'release_size')"
+ },
+ "downloads_last_operation_check": {
+ "name": "downloads_last_operation_check",
+ "value": "\"downloads\".\"last_operation\" in ('transfer', 'import')"
+ }
+ }
+ },
+ "managed_keys": {
+ "name": "managed_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "managed_keys_key_hash_unique": {
+ "name": "managed_keys_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_meta": {
+ "name": "own_catalog_meta",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_releases": {
+ "name": "own_catalog_releases",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "season": {
+ "name": "season",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "episode": {
+ "name": "episode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "own_catalog_releases_source_id_idx": {
+ "name": "own_catalog_releases_source_id_idx",
+ "columns": [
+ "source_id"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_title_key_idx": {
+ "name": "own_catalog_releases_title_key_idx",
+ "columns": [
+ "title_key"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_tmdb_id_idx": {
+ "name": "own_catalog_releases_tmdb_id_idx",
+ "columns": [
+ "tmdb_id"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_imdb_id_idx": {
+ "name": "own_catalog_releases_imdb_id_idx",
+ "columns": [
+ "imdb_id"
+ ],
+ "isUnique": false
+ },
+ "own_catalog_releases_tvdb_id_idx": {
+ "name": "own_catalog_releases_tvdb_id_idx",
+ "columns": [
+ "tvdb_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "own_catalog_releases_media_type_check": {
+ "name": "own_catalog_releases_media_type_check",
+ "value": "\"own_catalog_releases\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "own_catalog_source_reads": {
+ "name": "own_catalog_source_reads",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_source_titles": {
+ "name": "own_catalog_source_titles",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "own_catalog_source_titles_source_id_title_key_pk": {
+ "columns": [
+ "source_id",
+ "title_key"
+ ],
+ "name": "own_catalog_source_titles_source_id_title_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "own_catalog_titles": {
+ "name": "own_catalog_titles",
+ "columns": {
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_count": {
+ "name": "release_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total_size": {
+ "name": "total_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolutions": {
+ "name": "resolutions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "poster_url": {
+ "name": "poster_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "backdrop_url": {
+ "name": "backdrop_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "own_catalog_titles_revision_idx": {
+ "name": "own_catalog_titles_revision_idx",
+ "columns": [
+ "revision"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "own_catalog_titles_media_type_check": {
+ "name": "own_catalog_titles_media_type_check",
+ "value": "\"own_catalog_titles\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "peer_catalog_sync": {
+ "name": "peer_catalog_sync",
+ "columns": {
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "peer_catalog_titles": {
+ "name": "peer_catalog_titles",
+ "columns": {
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title_key": {
+ "name": "title_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_count": {
+ "name": "release_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total_size": {
+ "name": "total_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolutions": {
+ "name": "resolutions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "poster_url": {
+ "name": "poster_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "backdrop_url": {
+ "name": "backdrop_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "peer_catalog_titles_peer_id_title_key_pk": {
+ "columns": [
+ "peer_id",
+ "title_key"
+ ],
+ "name": "peer_catalog_titles_peer_id_title_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "peer_catalog_titles_media_type_check": {
+ "name": "peer_catalog_titles_media_type_check",
+ "value": "\"peer_catalog_titles\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "tmdb_metadata": {
+ "name": "tmdb_metadata",
+ "columns": {
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "tmdb_metadata_media_type_tmdb_id_pk": {
+ "columns": [
+ "media_type",
+ "tmdb_id"
+ ],
+ "name": "tmdb_metadata_media_type_tmdb_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "tmdb_metadata_media_type_check": {
+ "name": "tmdb_metadata_media_type_check",
+ "value": "\"tmdb_metadata\".\"media_type\" in ('movie', 'tv')"
+ }
+ }
+ },
+ "uploads": {
+ "name": "uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "transfer_id": {
+ "name": "transfer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_name": {
+ "name": "peer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_key_id": {
+ "name": "peer_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "total_bytes": {
+ "name": "total_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "range_start": {
+ "name": "range_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_bytes": {
+ "name": "sent_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "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
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uploads_transfer_id_unique": {
+ "name": "uploads_transfer_id_unique",
+ "columns": [
+ "transfer_id"
+ ],
+ "isUnique": true
+ },
+ "uploads_idempotency_key_unique": {
+ "name": "uploads_idempotency_key_unique",
+ "columns": [
+ "idempotency_key"
+ ],
+ "isUnique": true
+ },
+ "uploads_status_idx": {
+ "name": "uploads_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "uploads_started_at_idx": {
+ "name": "uploads_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "uploads_status_check": {
+ "name": "uploads_status_check",
+ "value": "\"uploads\".\"status\" in ('uploading', 'completed', 'unconfirmed')"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json
index 6491a44..a891d91 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -103,9 +103,16 @@
{
"idx": 14,
"version": "6",
- "when": 1794267794073,
+ "when": 1794180331966,
"tag": "0014_uploads",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "6",
+ "when": 1794270647120,
+ "tag": "0015_upload_transfers",
+ "breakpoints": true
}
]
}
\ No newline at end of file
From c62369fcd33ebc61b55b957466130cc417036922 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Thu, 12 Nov 2026 16:14:26 +0100
Subject: [PATCH 18/18] fix(db): wait on a busy SQLite lock instead of failing
---
apps/backend/src/database/connection.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/backend/src/database/connection.ts b/apps/backend/src/database/connection.ts
index fab5908..13472cf 100644
--- a/apps/backend/src/database/connection.ts
+++ b/apps/backend/src/database/connection.ts
@@ -47,6 +47,9 @@ export async function openDatabase({ appConfigPath }: { appConfigPath: string })
const sqlite = new Database(path)
sqlite.exec('pragma journal_mode = WAL')
+ // Wait for another connection's write lock (a second process on the same
+ // file, a backup tool) instead of failing with "database is locked".
+ sqlite.exec('pragma busy_timeout = 5000')
sqlite.exec('pragma foreign_keys = ON')
const db = drizzle({ client: sqlite, schema })