From 84185e1b68f7d93166a66d5143fc561d6dbdc421 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 21:13:22 +0100
Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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 68d3a076ad65367783254b8df3b0f41dbd1f9e30 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 22:26:28 +0100
Subject: [PATCH 13/28] feat: record catalog size and import time history and
serve dashboard stats
---
.../drizzle/0015_dashboard_history.sql | 10 +
apps/backend/drizzle/meta/0015_snapshot.json | 1107 +++++++++++++++++
apps/backend/drizzle/meta/_journal.json | 7 +
.../src/__tests__/qbittorrent-mapper.test.ts | 1 +
apps/backend/src/__tests__/stats.test.ts | 169 +++
apps/backend/src/database/schema.ts | 17 +
apps/backend/src/index.ts | 12 +
apps/backend/src/management-app.ts | 4 +-
.../modules/catalog/catalog-sync.service.ts | 3 +
.../modules/downloads/downloads.repository.ts | 6 +-
.../modules/downloads/downloads.service.ts | 1 +
.../src/modules/stats/stats.repository.ts | 73 ++
apps/backend/src/modules/stats/stats.ts | 187 +++
.../src/modules/status/status.controller.ts | 14 +
.../src/modules/status/status.router.ts | 21 +
15 files changed, 1630 insertions(+), 2 deletions(-)
create mode 100644 apps/backend/drizzle/0015_dashboard_history.sql
create mode 100644 apps/backend/drizzle/meta/0015_snapshot.json
create mode 100644 apps/backend/src/__tests__/stats.test.ts
create mode 100644 apps/backend/src/modules/stats/stats.repository.ts
create mode 100644 apps/backend/src/modules/stats/stats.ts
diff --git a/apps/backend/drizzle/0015_dashboard_history.sql b/apps/backend/drizzle/0015_dashboard_history.sql
new file mode 100644
index 0000000..bcaaaa6
--- /dev/null
+++ b/apps/backend/drizzle/0015_dashboard_history.sql
@@ -0,0 +1,10 @@
+CREATE TABLE `catalog_size_history` (
+ `day` text NOT NULL,
+ `source_id` text NOT NULL,
+ `source_name` text NOT NULL,
+ `titles` integer NOT NULL,
+ `bytes` integer NOT NULL,
+ PRIMARY KEY(`day`, `source_id`)
+);
+--> statement-breakpoint
+ALTER TABLE `downloads` ADD `imported_at` text;
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/0015_snapshot.json b/apps/backend/drizzle/meta/0015_snapshot.json
new file mode 100644
index 0000000..ed1ede4
--- /dev/null
+++ b/apps/backend/drizzle/meta/0015_snapshot.json
@@ -0,0 +1,1107 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "fddcd612-60ad-4dff-be2a-2b061512ac1a",
+ "prevId": "330e5a4a-3d65-4303-b438-55329bcd8ca7",
+ "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": {}
+ },
+ "catalog_size_history": {
+ "name": "catalog_size_history",
+ "columns": {
+ "day": {
+ "name": "day",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_name": {
+ "name": "source_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "titles": {
+ "name": "titles",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "catalog_size_history_day_source_id_pk": {
+ "columns": [
+ "day",
+ "source_id"
+ ],
+ "name": "catalog_size_history_day_source_id_pk"
+ }
+ },
+ "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
+ },
+ "imported_at": {
+ "name": "imported_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 b475d6d..5fffcab 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -106,6 +106,13 @@
"when": 1794180331966,
"tag": "0014_uploads",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "6",
+ "when": 1794173035939,
+ "tag": "0015_dashboard_history",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/backend/src/__tests__/qbittorrent-mapper.test.ts b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
index 7a2dccd..a3e7829 100644
--- a/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
+++ b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
@@ -29,6 +29,7 @@ function baseRecord(overrides: Partial = {}): DownloadRecord {
startedAt: '2026-06-06T00:00:00.000Z',
updatedAt: '2026-06-06T00:00:00.000Z',
completedAt: null,
+ importedAt: null,
error: null,
errorCode: null,
errorDetails: null,
diff --git a/apps/backend/src/__tests__/stats.test.ts b/apps/backend/src/__tests__/stats.test.ts
new file mode 100644
index 0000000..508592a
--- /dev/null
+++ b/apps/backend/src/__tests__/stats.test.ts
@@ -0,0 +1,169 @@
+import type { DownloadRecord } from '../modules/downloads/downloads.repository'
+import type { UploadRecord } from '../modules/uploads/uploads.repository'
+import { Database } from 'bun:sqlite'
+import { expect, test } from 'bun:test'
+import { drizzle } from 'drizzle-orm/bun-sqlite'
+import { runMigrations } from '../database/connection'
+import * as schema from '../database/schema'
+import { catalogSizeHistory, ownCatalogTitles, peerCatalogTitles } from '../database/schema'
+import { buildStats } from '../modules/stats/stats'
+import { StatsRepository } from '../modules/stats/stats.repository'
+
+const NOW = Date.parse('2026-11-08T12:00:00Z')
+
+function download(overrides: Partial): DownloadRecord {
+ return {
+ peerName: 'Ranieri',
+ status: 'imported',
+ downloadedBytes: 0,
+ errorCode: null,
+ startedAt: '2026-11-08T10:00:00Z',
+ completedAt: null,
+ importedAt: null,
+ ...overrides,
+ } as DownloadRecord
+}
+
+function upload(overrides: Partial): UploadRecord {
+ return {
+ peerName: 'Roz',
+ filename: 'Movie.mkv',
+ status: 'completed',
+ sentBytes: 0,
+ startedAt: '2026-11-08T10:00:00Z',
+ finishedAt: null,
+ ...overrides,
+ } as UploadRecord
+}
+
+test('buckets transfers, speed and import latency per day', () => {
+ const stats = buildStats([
+ download({
+ downloadedBytes: 1000,
+ startedAt: '2026-11-08T10:00:00Z',
+ completedAt: '2026-11-08T10:00:10Z',
+ importedAt: '2026-11-08T10:01:10Z',
+ }),
+ download({
+ peerName: 'Roz',
+ status: 'failed',
+ errorCode: 'PEER_UNREACHABLE',
+ downloadedBytes: 500,
+ }),
+ // Outside the window: counts toward lifetime peers, not the daily series.
+ download({ downloadedBytes: 7, completedAt: '2026-10-01T00:00:00Z' }),
+ ], [
+ upload({ sentBytes: 400, startedAt: '2026-11-07T09:00:00Z', finishedAt: '2026-11-07T09:00:04Z' }),
+ upload({ sentBytes: 100, status: 'aborted', finishedAt: '2026-11-07T09:30:00Z' }),
+ upload({
+ filename: 'Show.mkv',
+ peerName: 'Ranieri',
+ sentBytes: 50,
+ finishedAt: null,
+ }),
+ ], [], 3, NOW)
+
+ expect(stats.days).toEqual(['2026-11-06', '2026-11-07', '2026-11-08'])
+ expect(stats.transfers[2]).toEqual({
+ day: '2026-11-08',
+ downloadedBytes: 1000,
+ uploadedBytes: 0,
+ downloadBps: 100,
+ uploadBps: null,
+ })
+ // Aborted bytes count as moved but don't drag the speed down.
+ expect(stats.transfers[1]).toMatchObject({ uploadedBytes: 500, uploadBps: 100 })
+ expect(stats.importLatency[2]).toEqual({ day: '2026-11-08', avgSeconds: 60, count: 1 })
+ expect(stats.peers.gaveToMe).toEqual([
+ { name: 'Ranieri', bytes: 1007 },
+ { name: 'Roz', bytes: 500 },
+ ])
+ expect(stats.peers.tookFromMe).toEqual([
+ { name: 'Roz', bytes: 500 },
+ { name: 'Ranieri', bytes: 50 },
+ ])
+ expect(stats.failures).toEqual([
+ { code: 'PEER_UNREACHABLE', count: 1 },
+ ])
+ expect(stats.topTitles[0]).toEqual({
+ filename: 'Movie.mkv',
+ bytes: 500,
+ requests: 2,
+ peers: 1,
+ })
+})
+
+test('records one catalog size row per source per day', () => {
+ const database = new Database(':memory:')
+ const db = drizzle({ client: database, schema })
+ runMigrations(db)
+ const repo = new StatsRepository(db)
+ const title = {
+ mediaType: 'movie' as const,
+ title: 'A',
+ releaseCount: 1,
+ resolutions: [1080],
+ updatedAt: 'x',
+ }
+ db.insert(ownCatalogTitles).values([
+ {
+ ...title,
+ titleKey: 'a',
+ totalSize: 10,
+ revision: 1,
+ },
+ {
+ ...title,
+ titleKey: 'b',
+ totalSize: 99,
+ revision: 2,
+ deleted: true,
+ },
+ ]).run()
+ db.insert(peerCatalogTitles).values([
+ {
+ ...title,
+ peerId: 'p1',
+ titleKey: 'a',
+ totalSize: 5,
+ },
+ {
+ ...title,
+ peerId: 'p1',
+ titleKey: 'b',
+ totalSize: 6,
+ },
+ {
+ ...title,
+ peerId: 'gone',
+ titleKey: 'a',
+ totalSize: 1,
+ },
+ ]).run()
+
+ repo.recordCatalogSizes(new Map([
+ ['p1', 'Friend'],
+ ]), '2026-11-08')
+ repo.recordCatalogSizes(new Map([
+ ['p1', 'Friend renamed'],
+ ]), '2026-11-08')
+
+ expect(db.select().from(catalogSizeHistory).all()).toEqual([
+ {
+ day: '2026-11-08',
+ sourceId: 'self',
+ sourceName: 'This instance',
+ titles: 1,
+ bytes: 10,
+ },
+ {
+ day: '2026-11-08',
+ sourceId: 'p1',
+ sourceName: 'Friend renamed',
+ titles: 2,
+ bytes: 11,
+ },
+ ])
+ expect(repo.catalogSizesSince('2026-11-09')).toEqual([])
+ database.close()
+})
diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts
index d3ed165..201d111 100644
--- a/apps/backend/src/database/schema.ts
+++ b/apps/backend/src/database/schema.ts
@@ -37,6 +37,8 @@ export const downloads = sqliteTable('downloads', {
startedAt: text('started_at').notNull(),
updatedAt: text('updated_at').notNull(),
completedAt: text('completed_at'),
+ // When *arr confirmed the import; with completedAt, how long the import took.
+ importedAt: text('imported_at'),
error: text('error'),
// A DownloadErrorCode (download-error-codes.ts). No check constraint, so new
// codes don't need a migration.
@@ -131,6 +133,21 @@ export const uploads = sqliteTable('uploads', {
export type UploadRow = typeof uploads.$inferSelect
export type NewUploadRow = typeof uploads.$inferInsert
+// Catalog size per day: this instance's own catalog (sourceId `self`) and each
+// peer's, written after every catalog sync. One row per source per UTC day; a
+// later sync the same day overwrites it, so a day holds its last reading.
+export const catalogSizeHistory = sqliteTable('catalog_size_history', {
+ day: text('day').notNull(),
+ sourceId: text('source_id').notNull(),
+ sourceName: text('source_name').notNull(),
+ titles: integer('titles').notNull(),
+ bytes: integer('bytes').notNull(),
+}, t => [
+ primaryKey({
+ columns: [t.day, t.sourceId],
+ }),
+])
+
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 963250d..19010f0 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -24,6 +24,7 @@ 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 { StatsRepository } from './modules/stats/stats.repository'
import { UploadsRepository } from './modules/uploads/uploads.repository'
import { UploadsService } from './modules/uploads/uploads.service'
@@ -54,6 +55,7 @@ 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))
+const statsRepository = new StatsRepository(database.db)
// Peers connected to the previous run lost those transfers with it.
const interruptedUploads = uploadsService.repository.reconcileInterrupted()
@@ -86,6 +88,15 @@ const catalogSync = new CatalogSyncService({
peerCache: catalogCacheRepository,
metadata: tmdbMetadataRepository,
schedule: () => configService?.getCatalog().refreshSchedule ?? config.catalog.refreshSchedule,
+ // Record today's catalog sizes for the dashboard's history chart.
+ onSynced: () => {
+ try {
+ statsRepository.recordCatalogSizes(new Map(connectorManager.peers.map(p => [p.id, p.name ?? p.url])))
+ }
+ catch (err) {
+ logger.warn({ err }, 'Could not record catalog sizes')
+ }
+ },
})
catalogSync.start()
// A schedule edited in the UI applies without a restart.
@@ -173,6 +184,7 @@ function startManagementServer() {
downloadsService,
importWatcher,
uploadsService,
+ statsRepository,
apiKeysRepository,
catalogCacheRepository,
tmdbMetadataRepository,
diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts
index 12411e4..fece721 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 { StatsRepository } from './modules/stats/stats.repository'
import type { UploadsService } from './modules/uploads/uploads.service'
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
@@ -42,6 +43,7 @@ export function getManagementApp(params: {
downloadsService?: DownloadsService
importWatcher?: ImportWatcher
uploadsService?: UploadsService
+ statsRepository?: StatsRepository
apiKeysRepository?: ApiKeysRepository
catalogCacheRepository?: CatalogCacheRepository
tmdbMetadataRepository?: TmdbMetadataRepository
@@ -86,7 +88,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, params.uploadsService)
+ const statusController = new StatusController(params.connectors, params.downloadsRepository, params.uploadsService, params.statsRepository)
app.route('/', getStatusRouter(statusController))
app.route('/downloads', getDownloadsManagementRouter(new DownloadsManagementController(
diff --git a/apps/backend/src/modules/catalog/catalog-sync.service.ts b/apps/backend/src/modules/catalog/catalog-sync.service.ts
index 88e9509..bf9a815 100644
--- a/apps/backend/src/modules/catalog/catalog-sync.service.ts
+++ b/apps/backend/src/modules/catalog/catalog-sync.service.ts
@@ -50,6 +50,8 @@ export interface CatalogSyncDeps {
metadata?: Pick
// The current cron expression; read again on every reschedule().
schedule: () => string
+ // Called after each successful full sync, e.g. to record catalog sizes. Must not throw.
+ onSynced?: () => void
}
/**
@@ -207,6 +209,7 @@ export class CatalogSyncService {
])
})
this.syncedAt = new Date().toISOString()
+ this.deps.onSynced?.()
}
catch (err) {
logger.error({ err }, 'Catalog sync failed')
diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts
index 284728b..fff2b52 100644
--- a/apps/backend/src/modules/downloads/downloads.repository.ts
+++ b/apps/backend/src/modules/downloads/downloads.repository.ts
@@ -27,6 +27,7 @@ export interface DownloadRecord {
startedAt: string
updatedAt: string
completedAt: string | null
+ importedAt: string | null
error: string | null
errorCode: DownloadErrorCode | null
errorDetails: FileSystemErrorDetails | null
@@ -83,6 +84,7 @@ function toRecord(row: DownloadRow): DownloadRecord {
startedAt: row.startedAt,
updatedAt: row.updatedAt,
completedAt: row.completedAt,
+ importedAt: row.importedAt,
error: row.error,
errorCode: (row.errorCode as DownloadErrorCode | null) ?? null,
errorDetails: row.errorDetails ?? null,
@@ -217,15 +219,17 @@ export class DownloadsRepository {
// *arr finished importing the file into the library — terminal success. The row
// is kept (not deleted) so the downloads list doubles as a history.
markImported(id: number): void {
+ const timestamp = nowIso()
this.db.update(downloads)
.set({
status: 'imported',
+ importedAt: timestamp,
lastOperation: 'import',
operationFailed: false,
error: null,
errorCode: null,
errorDetails: null,
- updatedAt: nowIso(),
+ updatedAt: timestamp,
})
.where(eq(downloads.id, id))
.run()
diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts
index ae7976d..ea8927f 100644
--- a/apps/backend/src/modules/downloads/downloads.service.ts
+++ b/apps/backend/src/modules/downloads/downloads.service.ts
@@ -150,6 +150,7 @@ export class DownloadsService {
startedAt: '',
updatedAt: '',
completedAt: null,
+ importedAt: null,
error: null,
errorCode: null,
errorDetails: null,
diff --git a/apps/backend/src/modules/stats/stats.repository.ts b/apps/backend/src/modules/stats/stats.repository.ts
new file mode 100644
index 0000000..6b6dceb
--- /dev/null
+++ b/apps/backend/src/modules/stats/stats.repository.ts
@@ -0,0 +1,73 @@
+import type { AppDatabase } from '../../database/connection'
+import { count, eq, gte, sql, sum } from 'drizzle-orm'
+import { catalogSizeHistory, ownCatalogTitles, peerCatalogTitles } from '../../database/schema'
+
+export const OWN_CATALOG_SOURCE_ID = 'self'
+
+export type CatalogSizePoint = typeof catalogSizeHistory.$inferSelect
+
+/** History the dashboard can't rebuild from the download and upload rows. */
+export class StatsRepository {
+ constructor(private readonly db: AppDatabase) {}
+
+ /**
+ * Write today's catalog size for this instance and every peer. Re-running the
+ * same day overwrites that day's row. `peerNames` maps peer ids to the name to
+ * show; peers missing from it (no longer configured) are skipped.
+ */
+ recordCatalogSizes(peerNames: Map, day = new Date().toISOString().slice(0, 10)): void {
+ const own = this.db.select({
+ titles: count(),
+ bytes: sum(ownCatalogTitles.totalSize),
+ })
+ .from(ownCatalogTitles)
+ .where(eq(ownCatalogTitles.deleted, false))
+ .get()
+ const peers = this.db.select({
+ peerId: peerCatalogTitles.peerId,
+ titles: count(),
+ bytes: sum(peerCatalogTitles.totalSize),
+ })
+ .from(peerCatalogTitles)
+ .groupBy(peerCatalogTitles.peerId)
+ .all()
+
+ const rows = [
+ {
+ day,
+ sourceId: OWN_CATALOG_SOURCE_ID,
+ sourceName: 'This instance',
+ titles: own?.titles ?? 0,
+ bytes: Number(own?.bytes ?? 0),
+ },
+ ]
+ for (const peer of peers) {
+ const name = peerNames.get(peer.peerId)
+ if (name) {
+ rows.push({
+ day,
+ sourceId: peer.peerId,
+ sourceName: name,
+ titles: peer.titles,
+ bytes: Number(peer.bytes ?? 0),
+ })
+ }
+ }
+
+ this.db.insert(catalogSizeHistory)
+ .values(rows)
+ .onConflictDoUpdate({
+ target: [catalogSizeHistory.day, catalogSizeHistory.sourceId],
+ set: {
+ sourceName: sql`excluded.source_name`,
+ titles: sql`excluded.titles`,
+ bytes: sql`excluded.bytes`,
+ },
+ })
+ .run()
+ }
+
+ catalogSizesSince(day: string): CatalogSizePoint[] {
+ return this.db.select().from(catalogSizeHistory).where(gte(catalogSizeHistory.day, day)).orderBy(catalogSizeHistory.day).all()
+ }
+}
diff --git a/apps/backend/src/modules/stats/stats.ts b/apps/backend/src/modules/stats/stats.ts
new file mode 100644
index 0000000..72dbca8
--- /dev/null
+++ b/apps/backend/src/modules/stats/stats.ts
@@ -0,0 +1,187 @@
+import type { DownloadRecord } from '../downloads/downloads.repository'
+import type { UploadRecord } from '../uploads/uploads.repository'
+import type { CatalogSizePoint } from './stats.repository'
+
+const DAY_MS = 86_400_000
+const TOP_TITLES = 10
+
+export interface DailyTransfer {
+ day: string
+ downloadedBytes: number
+ uploadedBytes: number
+ /** Average over that day's finished transfers: their bytes over their wall time. */
+ downloadBps: number | null
+ uploadBps: number | null
+}
+
+export interface DashboardStats {
+ /** UTC days, oldest first; every series below has one entry per day. */
+ days: string[]
+ transfers: DailyTransfer[]
+ importLatency: { day: string, avgSeconds: number | null, count: number }[]
+ /** Lifetime bytes per peer, largest first. */
+ peers: {
+ gaveToMe: { name: string, bytes: number }[]
+ tookFromMe: { name: string, bytes: number }[]
+ }
+ /** Downloads that are failed right now, by error code, most common first. */
+ failures: { code: string, count: number }[]
+ /** Files peers pulled most from this instance, by bytes sent. */
+ topTitles: { filename: string, bytes: number, requests: number, peers: number }[]
+ catalog: {
+ sources: { id: string, name: string }[]
+ points: { day: string, sourceId: string, titles: number, bytes: number }[]
+ }
+}
+
+function dayOf(iso: string): string {
+ return iso.slice(0, 10)
+}
+
+function durationMs(from: string, to: string): number {
+ return Math.max(0, Date.parse(to) - Date.parse(from))
+}
+
+function ranked(totals: Map): { name: string, bytes: number }[] {
+ return [...totals].map(([name, bytes]) => ({ name, bytes })).filter(p => p.bytes > 0).sort((a, b) => b.bytes - a.bytes)
+}
+
+function add(map: Map, key: string, value: number): void {
+ map.set(key, (map.get(key) ?? 0) + value)
+}
+
+/** Everything the dashboard charts need, computed from the stored rows. */
+export function buildStats(
+ downloads: DownloadRecord[],
+ uploads: UploadRecord[],
+ catalogPoints: CatalogSizePoint[],
+ dayCount: number,
+ now = Date.now(),
+): DashboardStats {
+ const days = Array.from({ length: dayCount }, (_, i) => dayOf(new Date(now - (dayCount - 1 - i) * DAY_MS).toISOString()))
+ const inRange = new Set(days)
+ const buckets = new Map(days.map(day => [
+ day,
+ {
+ down: 0,
+ up: 0,
+ downTimed: 0,
+ downMs: 0,
+ upTimed: 0,
+ upMs: 0,
+ latencyMs: 0,
+ latencyCount: 0,
+ },
+ ]))
+
+ const gaveToMe = new Map()
+ const failures = new Map()
+ for (const d of downloads) {
+ add(gaveToMe, d.peerName, d.downloadedBytes)
+ if (d.status === 'failed')
+ add(failures, d.errorCode ?? 'UNKNOWN_ERROR', 1)
+ if (!d.completedAt)
+ continue
+ const bucket = buckets.get(dayOf(d.completedAt))
+ if (bucket) {
+ bucket.down += d.downloadedBytes
+ bucket.downTimed += d.downloadedBytes
+ bucket.downMs += durationMs(d.startedAt, d.completedAt)
+ }
+ if (!d.importedAt)
+ continue
+ const imported = buckets.get(dayOf(d.importedAt))
+ if (imported) {
+ imported.latencyMs += durationMs(d.completedAt, d.importedAt)
+ imported.latencyCount++
+ }
+ }
+
+ const tookFromMe = new Map()
+ const titles = new Map }>()
+ for (const u of uploads) {
+ add(tookFromMe, u.peerName, u.sentBytes)
+ const title = titles.get(u.filename) ?? {
+ bytes: 0,
+ requests: 0,
+ peers: new Set(),
+ }
+ title.bytes += u.sentBytes
+ title.requests++
+ title.peers.add(u.peerName)
+ titles.set(u.filename, title)
+ if (!u.finishedAt)
+ continue
+ const bucket = buckets.get(dayOf(u.finishedAt))
+ if (!bucket)
+ continue
+ bucket.up += u.sentBytes
+ // Only whole transfers say anything about speed; aborts end early by design.
+ if (u.status === 'completed') {
+ bucket.upTimed += u.sentBytes
+ bucket.upMs += durationMs(u.startedAt, u.finishedAt)
+ }
+ }
+
+ const sources = new Map()
+ const points = []
+ for (const p of catalogPoints) {
+ if (!inRange.has(p.day))
+ continue
+ // The newest name wins, so a renamed peer shows its current name.
+ sources.set(p.sourceId, p.sourceName)
+ points.push({
+ day: p.day,
+ sourceId: p.sourceId,
+ titles: p.titles,
+ bytes: p.bytes,
+ })
+ }
+
+ return {
+ days,
+ transfers: days.map((day) => {
+ const b = buckets.get(day)!
+ return {
+ day,
+ downloadedBytes: b.down,
+ uploadedBytes: b.up,
+ downloadBps: b.downMs > 0
+ ? (b.downTimed * 1000) / b.downMs
+ : null,
+ uploadBps: b.upMs > 0
+ ? (b.upTimed * 1000) / b.upMs
+ : null,
+ }
+ }),
+ importLatency: days.map((day) => {
+ const b = buckets.get(day)!
+ return {
+ day,
+ avgSeconds: b.latencyCount > 0
+ ? Math.round(b.latencyMs / b.latencyCount / 1000)
+ : null,
+ count: b.latencyCount,
+ }
+ }),
+ peers: {
+ gaveToMe: ranked(gaveToMe),
+ tookFromMe: ranked(tookFromMe),
+ },
+ failures: [...failures].map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count),
+ topTitles: [...titles]
+ .map(([filename, t]) => ({
+ filename,
+ bytes: t.bytes,
+ requests: t.requests,
+ peers: t.peers.size,
+ }))
+ .filter(t => t.bytes > 0)
+ .sort((a, b) => b.bytes - a.bytes)
+ .slice(0, TOP_TITLES),
+ catalog: {
+ sources: [...sources].map(([id, name]) => ({ id, name })),
+ points,
+ },
+ }
+}
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 0d842b7..db45837 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -4,10 +4,12 @@ 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 { StatsRepository } from '../stats/stats.repository'
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'
+import { buildStats } from '../stats/stats'
// 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
@@ -112,6 +114,7 @@ export class StatusController {
private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[], subscribe?: (subscriber: () => void) => () => void },
private readonly downloads?: DownloadsRepository,
private readonly uploads?: UploadsService,
+ private readonly stats?: StatsRepository,
) {}
private enrichUpload = (u: UploadRecord) => enrichUpload(u)
@@ -221,6 +224,17 @@ export class StatusController {
}
}
+ /** Chart data for the dashboard: the last `days` UTC days plus lifetime rankings. */
+ getStats(days: number) {
+ const since = new Date(Date.now() - (days - 1) * 86_400_000).toISOString().slice(0, 10)
+ return buildStats(
+ this.downloads?.list() ?? [],
+ this.uploads?.list() ?? [],
+ this.stats?.catalogSizesSince(since) ?? [],
+ days,
+ )
+ }
+
listUploads() {
const records = this.uploads?.list() ?? []
return {
diff --git a/apps/backend/src/modules/status/status.router.ts b/apps/backend/src/modules/status/status.router.ts
index 4284f21..fd5309d 100644
--- a/apps/backend/src/modules/status/status.router.ts
+++ b/apps/backend/src/modules/status/status.router.ts
@@ -91,5 +91,26 @@ export function getStatusRouter(controller: StatusController) {
},
}), c => streamSnapshots(c, () => controller.listUploads(), subscriber => controller.subscribeUploads(subscriber)))
+ app.get('/stats', describeRoute({
+ tags: ['Status'],
+ summary: 'Dashboard statistics',
+ description: 'Per-day transfer volume, average speed and import latency for the last `days` UTC days (default 30, at most 365), lifetime bytes per peer, current failures by error code, the files peers pulled most, and the catalog size history.',
+ security: [
+ { 'X-Management-Key': [] },
+ ],
+ responses: {
+ 200: {
+ description: 'Dashboard statistics',
+ content: { 'application/json': {} },
+ },
+ },
+ }), (c) => {
+ const requested = Number(c.req.query('days') ?? 30)
+ const days = Number.isInteger(requested)
+ ? Math.min(365, Math.max(1, requested))
+ : 30
+ return c.json(controller.getStats(days))
+ })
+
return app
}
From 0478b6ef41552db48a77cbf09659c2a45523177f Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 22:29:25 +0100
Subject: [PATCH 14/28] feat(ui): redesign the dashboard with collapsible
sections and history charts
---
apps/ui/app/assets/css/main.css | 25 ++
apps/ui/app/components/ColumnChart.vue | 89 ++++++
apps/ui/app/components/DashSection.vue | 26 ++
apps/ui/app/components/LineChart.vue | 160 ++++++++++
apps/ui/app/components/RankBars.vue | 30 ++
apps/ui/app/pages/index.vue | 406 +++++++++++++++++--------
apps/ui/app/types/management.ts | 23 ++
apps/ui/app/utils/format.ts | 5 +
website/guide/management-ui.md | 27 +-
9 files changed, 665 insertions(+), 126 deletions(-)
create mode 100644 apps/ui/app/components/ColumnChart.vue
create mode 100644 apps/ui/app/components/DashSection.vue
create mode 100644 apps/ui/app/components/LineChart.vue
create mode 100644 apps/ui/app/components/RankBars.vue
diff --git a/apps/ui/app/assets/css/main.css b/apps/ui/app/assets/css/main.css
index 7286ec8..1873e90 100644
--- a/apps/ui/app/assets/css/main.css
+++ b/apps/ui/app/assets/css/main.css
@@ -7,3 +7,28 @@
with a system-mono fallback so an offline build still renders cleanly. */
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
}
+
+/* Dashboard chart series, in fixed order: a series keeps its slot, never its rank.
+ The order is CVD-checked for adjacent pairs; dark mode has its own steps.
+ Downloads are slot 1 and uploads slot 2 everywhere. */
+:root {
+ --viz-1: #2a78d6;
+ --viz-2: #eb6834;
+ --viz-3: #1baf7a;
+ --viz-4: #eda100;
+ --viz-5: #e87ba4;
+ --viz-6: #008300;
+ --viz-7: #6250d6;
+ --viz-8: #e34948;
+}
+
+.dark {
+ --viz-1: #3987e5;
+ --viz-2: #d95926;
+ --viz-3: #199e70;
+ --viz-4: #c98500;
+ --viz-5: #d55181;
+ --viz-6: #008300;
+ --viz-7: #9085e9;
+ --viz-8: #e66767;
+}
diff --git a/apps/ui/app/components/ColumnChart.vue b/apps/ui/app/components/ColumnChart.vue
new file mode 100644
index 0000000..0ab9883
--- /dev/null
+++ b/apps/ui/app/components/ColumnChart.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+ {{ s.name }}
+
+
+
+
+
+ {{ format(max) }}
+ 0
+
+
+
+
+ {{ formatDay(days[0]!) }}
+ {{ formatDay(days.at(-1)!) }}
+
+
+
+
+
+ Day
+
+ {{ s.name }}
+
+
+
+
+
+ {{ formatDay(day) }}
+
+ {{ format(s.values[i] ?? 0) }}
+
+
+
+
+
+
diff --git a/apps/ui/app/components/DashSection.vue b/apps/ui/app/components/DashSection.vue
new file mode 100644
index 0000000..4b13cd2
--- /dev/null
+++ b/apps/ui/app/components/DashSection.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
diff --git a/apps/ui/app/components/LineChart.vue b/apps/ui/app/components/LineChart.vue
new file mode 100644
index 0000000..d809c37
--- /dev/null
+++ b/apps/ui/app/components/LineChart.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+ {{ s.name }}
+
+
+
+
+
+ {{ format(max) }}
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDay(days[hover]!) }}
+
+
+
+ {{ s.name }}
+ {{ s.values[hover] == null ? '—' : format(s.values[hover]!) }}
+
+
+
+
+
+ {{ formatDay(days[0]!) }}
+ {{ formatDay(days.at(-1)!) }}
+
+
+
+
+
+ Day
+
+ {{ s.name }}
+
+
+
+
+
+ {{ formatDay(day) }}
+
+ {{ s.values[i] == null ? 'no data' : format(s.values[i]!) }}
+
+
+
+
+
+
diff --git a/apps/ui/app/components/RankBars.vue b/apps/ui/app/components/RankBars.vue
new file mode 100644
index 0000000..cc0267e
--- /dev/null
+++ b/apps/ui/app/components/RankBars.vue
@@ -0,0 +1,30 @@
+
+
+
+
+ {{ empty }}
+
+
+
diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue
index 691bbf3..a56f16e 100644
--- a/apps/ui/app/pages/index.vue
+++ b/apps/ui/app/pages/index.vue
@@ -1,5 +1,6 @@
@@ -46,133 +108,23 @@ function serverRole(server: { source: boolean, destination: boolean }) {
-
-
-
-
-
-
- Lifetime
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
- Transfers
-
-
-
-
-
-
-
- {{ overview.downloads.byStatus.downloading ?? 0 }}
- Downloading
-
-
- {{ overview.downloads.byStatus.import_queued ?? 0 }}
- Queued for import
-
-
- {{ overview.downloads.byStatus.failed ?? 0 }}
- Failed
-
-
-
-
- No active transfers.
-
-
-
-
-
- {{ d.filename }}
-
-
- from {{ d.peerName }}
-
-
-
-
-
- {{ formatBytes(d.downloadedBytes) }} / {{ formatBytes(d.totalBytes) }}
-
-
-
- {{ formatTransferRate(d.speedBps, d.etaSeconds) }}
-
-
-
-
-
-
-
-
-
-
- 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 }}
-
-
-
-
- Sending {{ formatBytes(u.requestedBytes) }} of {{ formatBytes(u.totalBytes) }} · started {{ formatAgo(u.startedAt) }} ago
-
-
-
-
-
+
+
-
-
- Connections
-
+
-
Peers
@@ -194,7 +146,6 @@ function serverRole(server: { source: boolean, destination: boolean }) {
-
Servers
@@ -216,7 +167,214 @@ function serverRole(server: { source: boolean, destination: boolean }) {
-
+
+
+
+
+
+
+
+
+ Data moved per day
+
+
+ Bytes of finished transfers, last 30 days
+
+
+
+ Nothing moved in the last 30 days.
+
+
+
+
+ Average speed
+
+
+ Bytes over transfer time for each day's finished transfers, including retry waits
+
+
+
+ No finished transfers in the last 30 days.
+
+
+
+
+
+
+
+
+
+
+ Pulled the most from you
+
+
+
+
+
+ You pulled the most from
+
+
+
+
+
+
+
+
+
+
+
+ Catalog size
+
+
+ Titles in your catalog and each peer's, recorded at every catalog sync
+
+
+
+ No catalog sync recorded yet.
+
+
+
+
+ Most pulled from you
+
+
+
+
+
+
+
+
+
+
+
+ Import time
+
+
+ Average time from download finished to *arr import, by import day
+
+
+
+ No imports recorded in the last 30 days.
+
+
+
+
+
+ Failed downloads by error code
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ overview.downloads.byStatus.downloading ?? 0 }}
+ Downloading
+
+
+ {{ overview.downloads.byStatus.import_queued ?? 0 }}
+ Queued for import
+
+
+ {{ overview.downloads.byStatus.failed ?? 0 }}
+ Failed
+
+
+
+
+
+
+ No active downloads.
+
+
+
+
+
+ {{ d.filename }}
+
+
+ from {{ d.peerName }}
+
+
+
+
+
+ {{ formatBytes(d.downloadedBytes) }} / {{ formatBytes(d.totalBytes) }}
+
+
+
+ {{ formatTransferRate(d.speedBps, d.etaSeconds) }}
+
+
+
+
+
+
+
+
+
+ {{ overview.uploads.byStatus.uploading ?? 0 }}
+ Uploading
+
+
+ {{ overview.uploads.byStatus.completed ?? 0 }}
+ Completed
+
+
+
+
+
+
+ No peer is pulling from you right now.
+
+
+
+
+
+ {{ u.filename }}
+
+
+ to {{ u.peerName }}
+
+
+
+
+ Sending {{ formatBytes(u.requestedBytes) }} of {{ formatBytes(u.totalBytes) }} · started {{ formatAgo(u.startedAt) }} ago
+
+
+
+
+
+
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts
index ea93759..2c3ed83 100644
--- a/apps/ui/app/types/management.ts
+++ b/apps/ui/app/types/management.ts
@@ -325,3 +325,26 @@ export interface LogRecord {
trace_id?: string
[key: string]: unknown
}
+
+// GET /stats: the dashboard charts. Daily series cover `days` (UTC, oldest first).
+export interface DashboardStats {
+ days: string[]
+ transfers: {
+ day: string
+ downloadedBytes: number
+ uploadedBytes: number
+ downloadBps: number | null
+ uploadBps: number | null
+ }[]
+ importLatency: { day: string, avgSeconds: number | null, count: number }[]
+ peers: {
+ gaveToMe: { name: string, bytes: number }[]
+ tookFromMe: { name: string, bytes: number }[]
+ }
+ failures: { code: string, count: number }[]
+ topTitles: { filename: string, bytes: number, requests: number, peers: number }[]
+ catalog: {
+ sources: { id: string, name: string }[]
+ points: { day: string, sourceId: string, titles: number, bytes: number }[]
+ }
+}
diff --git a/apps/ui/app/utils/format.ts b/apps/ui/app/utils/format.ts
index 2b0402b..ed10dd8 100644
--- a/apps/ui/app/utils/format.ts
+++ b/apps/ui/app/utils/format.ts
@@ -8,6 +8,11 @@ export function formatTransferRate(speedBps: number | null, etaSeconds: number |
: `${rate} · ${formatDurationMs(etaSeconds * 1000)} left`
}
+/** A UTC `YYYY-MM-DD` day as "Nov 8". */
+export function formatDay(day: string): string {
+ return new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
+}
+
/** `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/website/guide/management-ui.md b/website/guide/management-ui.md
index 4b7122d..b37422c 100644
--- a/website/guide/management-ui.md
+++ b/website/guide/management-ui.md
@@ -10,8 +10,8 @@ instance without hand-editing `config.jsonc`. With the
`jack-ui` service at **http://localhost:3000**, and it's the easiest way to
operate jack day to day:
-- ***Overview***: your configured servers and peers, and whether each one
- initialized cleanly, with transfer activity updating live.
+- ***Overview***: lifetime totals, your servers and peers, charts of the last
+ 30 days, and transfer activity updating live. See [Overview](#overview).
- ***Catalog***: browse everything your peers share, and request titles into
your library.
- ***Downloads***: inspect, cancel, retry, and delete in-flight or finished
@@ -34,6 +34,29 @@ 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.
+## Overview
+
+The dashboard is a stack of sections, each of which folds away:
+
+- **Lifetime**: files imported, bytes downloaded and uploaded, the
+ upload/download ratio, and the number of transfers.
+- **Connections**: each peer and server, and whether it is reachable.
+- **Activity**: bytes moved per day and the average transfer speed per day.
+ The speed counts each finished transfer from start to finish, so time spent
+ waiting between retries pulls it down.
+- **Peer split**: which peers pulled the most from you, and which you pulled
+ the most from.
+- **Library**: the title count of your catalog and each peer's over time, and
+ the files peers pulled from you most.
+- **Imports and failures**: the average time from a finished download to its
+ *arr import, and the failed downloads grouped by
+ [error code](/guide/troubleshooting#download-error-codes).
+- **Transfers**: downloads and uploads running now, with live progress.
+
+Charts cover the last 30 days in UTC and refresh every minute. The catalog
+history starts with the first catalog sync after upgrading, and import times
+are recorded for imports from then on.
+
## Uploads
When a peer downloads a file from your library, jack records it as an upload.
From 9322e70645e54d580a7425264bf3931ec7bac2da Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Sun, 8 Nov 2026 23:47:11 +0100
Subject: [PATCH 15/28] fix: base dashboard upload speed on peer receipts and
regenerate the history migration
---
apps/backend/drizzle/meta/0015_snapshot.json | 13 ++++++++++---
apps/backend/drizzle/meta/_journal.json | 2 +-
apps/backend/src/__tests__/stats.test.ts | 17 +++++++++--------
apps/backend/src/modules/stats/stats.ts | 7 ++++---
4 files changed, 24 insertions(+), 15 deletions(-)
diff --git a/apps/backend/drizzle/meta/0015_snapshot.json b/apps/backend/drizzle/meta/0015_snapshot.json
index ed1ede4..764cd5e 100644
--- a/apps/backend/drizzle/meta/0015_snapshot.json
+++ b/apps/backend/drizzle/meta/0015_snapshot.json
@@ -1,8 +1,8 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "fddcd612-60ad-4dff-be2a-2b061512ac1a",
- "prevId": "330e5a4a-3d65-4303-b438-55329bcd8ca7",
+ "id": "1a854ff0-e165-4eef-91e0-eb3b7a620ed8",
+ "prevId": "f6d7a728-eaa3-4692-9836-71fd937cbe60",
"tables": {
"api_keys": {
"name": "api_keys",
@@ -1031,6 +1031,13 @@
"autoincrement": false,
"default": 0
},
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"status": {
"name": "status",
"type": "text",
@@ -1089,7 +1096,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 5fffcab..385108d 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -110,7 +110,7 @@
{
"idx": 15,
"version": "6",
- "when": 1794173035939,
+ "when": 1794177973906,
"tag": "0015_dashboard_history",
"breakpoints": true
}
diff --git a/apps/backend/src/__tests__/stats.test.ts b/apps/backend/src/__tests__/stats.test.ts
index 508592a..fef64f1 100644
--- a/apps/backend/src/__tests__/stats.test.ts
+++ b/apps/backend/src/__tests__/stats.test.ts
@@ -53,12 +53,14 @@ test('buckets transfers, speed and import latency per day', () => {
// Outside the window: counts toward lifetime peers, not the daily series.
download({ downloadedBytes: 7, completedAt: '2026-10-01T00:00:00Z' }),
], [
- upload({ sentBytes: 400, startedAt: '2026-11-07T09:00:00Z', finishedAt: '2026-11-07T09:00:04Z' }),
- upload({ sentBytes: 100, status: 'aborted', finishedAt: '2026-11-07T09:30:00Z' }),
+ // Confirmed by a receipt: 400 bytes in 4 s.
+ upload({ sentBytes: 400, durationMs: 4000, finishedAt: '2026-11-07T09:00:04Z' }),
+ // No receipt, so nothing is known to have arrived.
+ upload({ sentBytes: 0, status: 'aborted', finishedAt: '2026-11-07T09:30:00Z' }),
upload({
filename: 'Show.mkv',
peerName: 'Ranieri',
- sentBytes: 50,
+ status: 'uploading',
finishedAt: null,
}),
], [], 3, NOW)
@@ -71,23 +73,22 @@ test('buckets transfers, speed and import latency per day', () => {
downloadBps: 100,
uploadBps: null,
})
- // Aborted bytes count as moved but don't drag the speed down.
- expect(stats.transfers[1]).toMatchObject({ uploadedBytes: 500, uploadBps: 100 })
+ // Upload speed is the receipt's bytes over its duration.
+ expect(stats.transfers[1]).toMatchObject({ uploadedBytes: 400, uploadBps: 100 })
expect(stats.importLatency[2]).toEqual({ day: '2026-11-08', avgSeconds: 60, count: 1 })
expect(stats.peers.gaveToMe).toEqual([
{ name: 'Ranieri', bytes: 1007 },
{ name: 'Roz', bytes: 500 },
])
expect(stats.peers.tookFromMe).toEqual([
- { name: 'Roz', bytes: 500 },
- { name: 'Ranieri', bytes: 50 },
+ { name: 'Roz', bytes: 400 },
])
expect(stats.failures).toEqual([
{ code: 'PEER_UNREACHABLE', count: 1 },
])
expect(stats.topTitles[0]).toEqual({
filename: 'Movie.mkv',
- bytes: 500,
+ bytes: 400,
requests: 2,
peers: 1,
})
diff --git a/apps/backend/src/modules/stats/stats.ts b/apps/backend/src/modules/stats/stats.ts
index 72dbca8..6dc7865 100644
--- a/apps/backend/src/modules/stats/stats.ts
+++ b/apps/backend/src/modules/stats/stats.ts
@@ -115,11 +115,12 @@ export function buildStats(
const bucket = buckets.get(dayOf(u.finishedAt))
if (!bucket)
continue
+ // Bytes and time come from the downloading jack's receipt, so only confirmed
+ // uploads count; files go out zero-copy and jack never measures them itself.
bucket.up += u.sentBytes
- // Only whole transfers say anything about speed; aborts end early by design.
- if (u.status === 'completed') {
+ if (u.status === 'completed' && u.durationMs) {
bucket.upTimed += u.sentBytes
- bucket.upMs += durationMs(u.startedAt, u.finishedAt)
+ bucket.upMs += u.durationMs
}
}
From 3fefc9023bf8b3d4853eef39eb1e9f268298d55a Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 01:02:46 +0100
Subject: [PATCH 16/28] fix: regenerate the dashboard history migration on top
of the uploads one
---
apps/backend/drizzle/meta/0015_snapshot.json | 11 +++++++++--
apps/backend/drizzle/meta/_journal.json | 2 +-
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/apps/backend/drizzle/meta/0015_snapshot.json b/apps/backend/drizzle/meta/0015_snapshot.json
index 764cd5e..f562ca9 100644
--- a/apps/backend/drizzle/meta/0015_snapshot.json
+++ b/apps/backend/drizzle/meta/0015_snapshot.json
@@ -1,8 +1,8 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "1a854ff0-e165-4eef-91e0-eb3b7a620ed8",
- "prevId": "f6d7a728-eaa3-4692-9836-71fd937cbe60",
+ "id": "937d843c-ab35-4298-a440-95ea4c840303",
+ "prevId": "c9f970ae-d2a3-41bd-b206-35a480a193bf",
"tables": {
"api_keys": {
"name": "api_keys",
@@ -987,6 +987,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 385108d..b277663 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -110,7 +110,7 @@
{
"idx": 15,
"version": "6",
- "when": 1794177973906,
+ "when": 1794182530810,
"tag": "0015_dashboard_history",
"breakpoints": true
}
From 1f98365c3f59ee3453122114d2a11db50f98cf79 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 01:09:14 +0100
Subject: [PATCH 17/28] 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 18/28] 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 f4a30c07bc3b49626226f7a39bc9be4532255f0f Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 01:16:01 +0100
Subject: [PATCH 19/28] fix: aggregate dashboard stats in SQL by stable peer
ids, show chart load errors, improve chart accessibility
---
apps/backend/src/__tests__/stats.test.ts | 98 ++++++----
.../src/modules/stats/stats.repository.ts | 177 +++++++++++++++--
apps/backend/src/modules/stats/stats.ts | 179 ++----------------
.../src/modules/status/status.controller.ts | 22 ++-
apps/ui/app/components/DashSection.vue | 21 +-
apps/ui/app/components/LineChart.vue | 26 ++-
apps/ui/app/components/RankBars.vue | 5 +-
apps/ui/app/pages/index.vue | 19 +-
apps/ui/app/types/management.ts | 5 +-
9 files changed, 318 insertions(+), 234 deletions(-)
diff --git a/apps/backend/src/__tests__/stats.test.ts b/apps/backend/src/__tests__/stats.test.ts
index fef64f1..bfea96e 100644
--- a/apps/backend/src/__tests__/stats.test.ts
+++ b/apps/backend/src/__tests__/stats.test.ts
@@ -1,43 +1,62 @@
-import type { DownloadRecord } from '../modules/downloads/downloads.repository'
-import type { UploadRecord } from '../modules/uploads/uploads.repository'
+import type { NewDownloadRow, NewUploadRow } from '../database/schema'
import { Database } from 'bun:sqlite'
import { expect, test } from 'bun:test'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { runMigrations } from '../database/connection'
import * as schema from '../database/schema'
-import { catalogSizeHistory, ownCatalogTitles, peerCatalogTitles } from '../database/schema'
-import { buildStats } from '../modules/stats/stats'
+import { catalogSizeHistory, downloads, ownCatalogTitles, peerCatalogTitles, uploads } from '../database/schema'
import { StatsRepository } from '../modules/stats/stats.repository'
const NOW = Date.parse('2026-11-08T12:00:00Z')
-function download(overrides: Partial): DownloadRecord {
+function setup() {
+ const database = new Database(':memory:')
+ const db = drizzle({ client: database, schema })
+ runMigrations(db)
return {
+ database,
+ db,
+ repo: new StatsRepository(db),
+ }
+}
+
+function download(overrides: Partial): NewDownloadRow {
+ return {
+ torrentFilename: 'x.torrent',
+ peerId: 'peer-r',
peerName: 'Ranieri',
+ itemId: '1',
+ filename: 'Movie.mkv',
+ destPath: '/d/Movie.mkv',
+ partPath: '/d/Movie.mkv.part',
+ releaseSize: 1,
+ releaseJson: '{}',
status: 'imported',
downloadedBytes: 0,
- errorCode: null,
startedAt: '2026-11-08T10:00:00Z',
- completedAt: null,
- importedAt: null,
+ updatedAt: '2026-11-08T10:00:00Z',
...overrides,
- } as DownloadRecord
+ }
}
-function upload(overrides: Partial): UploadRecord {
+function upload(overrides: Partial): NewUploadRow {
return {
peerName: 'Roz',
+ peerKeyId: 7,
+ itemId: '1',
filename: 'Movie.mkv',
+ totalBytes: 1000,
+ requestedBytes: 400,
status: 'completed',
- sentBytes: 0,
- startedAt: '2026-11-08T10:00:00Z',
- finishedAt: null,
+ startedAt: '2026-11-07T09:00:00Z',
+ updatedAt: '2026-11-07T09:00:00Z',
...overrides,
- } as UploadRecord
+ }
}
-test('buckets transfers, speed and import latency per day', () => {
- const stats = buildStats([
+test('aggregates daily series and rankings in SQL', () => {
+ const { database, db, repo } = setup()
+ db.insert(downloads).values([
download({
downloadedBytes: 1000,
startedAt: '2026-11-08T10:00:00Z',
@@ -45,6 +64,7 @@ test('buckets transfers, speed and import latency per day', () => {
importedAt: '2026-11-08T10:01:10Z',
}),
download({
+ peerId: 'peer-z',
peerName: 'Roz',
status: 'failed',
errorCode: 'PEER_UNREACHABLE',
@@ -52,18 +72,25 @@ test('buckets transfers, speed and import latency per day', () => {
}),
// Outside the window: counts toward lifetime peers, not the daily series.
download({ downloadedBytes: 7, completedAt: '2026-10-01T00:00:00Z' }),
- ], [
+ // Same peer id renamed later: one entry, under its latest name.
+ download({ peerName: 'Ranieri (new)', downloadedBytes: 3, updatedAt: '2026-11-08T11:00:00Z' }),
+ ]).run()
+ db.insert(uploads).values([
// Confirmed by a receipt: 400 bytes in 4 s.
upload({ sentBytes: 400, durationMs: 4000, finishedAt: '2026-11-07T09:00:04Z' }),
// No receipt, so nothing is known to have arrived.
- upload({ sentBytes: 0, status: 'aborted', finishedAt: '2026-11-07T09:30:00Z' }),
+ upload({ status: 'aborted', finishedAt: '2026-11-07T09:30:00Z' }),
+ // Another key with the same name stays a separate peer.
upload({
+ peerKeyId: 8,
filename: 'Show.mkv',
- peerName: 'Ranieri',
- status: 'uploading',
- finishedAt: null,
+ sentBytes: 50,
+ durationMs: 1000,
+ finishedAt: '2026-11-07T10:00:00Z',
}),
- ], [], 3, NOW)
+ ]).run()
+
+ const stats = repo.dashboard(3, NOW)
expect(stats.days).toEqual(['2026-11-06', '2026-11-07', '2026-11-08'])
expect(stats.transfers[2]).toEqual({
@@ -73,15 +100,15 @@ test('buckets transfers, speed and import latency per day', () => {
downloadBps: 100,
uploadBps: null,
})
- // Upload speed is the receipt's bytes over its duration.
- expect(stats.transfers[1]).toMatchObject({ uploadedBytes: 400, uploadBps: 100 })
+ expect(stats.transfers[1]).toMatchObject({ uploadedBytes: 450, uploadBps: 90 })
expect(stats.importLatency[2]).toEqual({ day: '2026-11-08', avgSeconds: 60, count: 1 })
expect(stats.peers.gaveToMe).toEqual([
- { name: 'Ranieri', bytes: 1007 },
- { name: 'Roz', bytes: 500 },
+ { id: 'peer-r', name: 'Ranieri (new)', bytes: 1010 },
+ { id: 'peer-z', name: 'Roz', bytes: 500 },
])
expect(stats.peers.tookFromMe).toEqual([
- { name: 'Roz', bytes: 400 },
+ { id: '7', name: 'Roz', bytes: 400 },
+ { id: '8', name: 'Roz', bytes: 50 },
])
expect(stats.failures).toEqual([
{ code: 'PEER_UNREACHABLE', count: 1 },
@@ -92,13 +119,11 @@ test('buckets transfers, speed and import latency per day', () => {
requests: 2,
peers: 1,
})
+ database.close()
})
-test('records one catalog size row per source per day', () => {
- const database = new Database(':memory:')
- const db = drizzle({ client: database, schema })
- runMigrations(db)
- const repo = new StatsRepository(db)
+test('records one catalog size row per source per day, zero for an empty catalog', () => {
+ const { database, db, repo } = setup()
const title = {
mediaType: 'movie' as const,
title: 'A',
@@ -145,8 +170,10 @@ test('records one catalog size row per source per day', () => {
repo.recordCatalogSizes(new Map([
['p1', 'Friend'],
]), '2026-11-08')
+ // Same day again: p1 renamed, and p2 is configured with nothing cached.
repo.recordCatalogSizes(new Map([
['p1', 'Friend renamed'],
+ ['p2', 'Empty'],
]), '2026-11-08')
expect(db.select().from(catalogSizeHistory).all()).toEqual([
@@ -164,6 +191,13 @@ test('records one catalog size row per source per day', () => {
titles: 2,
bytes: 11,
},
+ {
+ day: '2026-11-08',
+ sourceId: 'p2',
+ sourceName: 'Empty',
+ titles: 0,
+ bytes: 0,
+ },
])
expect(repo.catalogSizesSince('2026-11-09')).toEqual([])
database.close()
diff --git a/apps/backend/src/modules/stats/stats.repository.ts b/apps/backend/src/modules/stats/stats.repository.ts
index 6b6dceb..16f3bd6 100644
--- a/apps/backend/src/modules/stats/stats.repository.ts
+++ b/apps/backend/src/modules/stats/stats.repository.ts
@@ -1,19 +1,29 @@
import type { AppDatabase } from '../../database/connection'
+import type { DashboardStats, RankedPeer } from './stats'
import { count, eq, gte, sql, sum } from 'drizzle-orm'
import { catalogSizeHistory, ownCatalogTitles, peerCatalogTitles } from '../../database/schema'
+import { lastDays } from './stats'
export const OWN_CATALOG_SOURCE_ID = 'self'
+const TOP_TITLES = 10
+// SQLite's julianday() counts days; this turns a difference into milliseconds.
+const MS_PER_DAY = 86_400_000
+
export type CatalogSizePoint = typeof catalogSizeHistory.$inferSelect
-/** History the dashboard can't rebuild from the download and upload rows. */
+/**
+ * The dashboard's numbers. Everything is aggregated in SQLite: the download and
+ * upload history is kept forever and never loaded into memory for a chart.
+ */
export class StatsRepository {
constructor(private readonly db: AppDatabase) {}
/**
- * Write today's catalog size for this instance and every peer. Re-running the
- * same day overwrites that day's row. `peerNames` maps peer ids to the name to
- * show; peers missing from it (no longer configured) are skipped.
+ * Write today's catalog size for this instance and every configured peer
+ * (`peerNames` maps peer id to display name). A peer with no cached titles gets
+ * zero, so an emptied catalog reads as zero rather than a gap. Re-running the
+ * same day overwrites that day's row.
*/
recordCatalogSizes(peerNames: Map, day = new Date().toISOString().slice(0, 10)): void {
const own = this.db.select({
@@ -31,6 +41,7 @@ export class StatsRepository {
.from(peerCatalogTitles)
.groupBy(peerCatalogTitles.peerId)
.all()
+ const byPeer = new Map(peers.map(p => [p.peerId, p]))
const rows = [
{
@@ -41,17 +52,15 @@ export class StatsRepository {
bytes: Number(own?.bytes ?? 0),
},
]
- for (const peer of peers) {
- const name = peerNames.get(peer.peerId)
- if (name) {
- rows.push({
- day,
- sourceId: peer.peerId,
- sourceName: name,
- titles: peer.titles,
- bytes: Number(peer.bytes ?? 0),
- })
- }
+ for (const [peerId, name] of peerNames) {
+ const peer = byPeer.get(peerId)
+ rows.push({
+ day,
+ sourceId: peerId,
+ sourceName: name,
+ titles: peer?.titles ?? 0,
+ bytes: Number(peer?.bytes ?? 0),
+ })
}
this.db.insert(catalogSizeHistory)
@@ -70,4 +79,142 @@ export class StatsRepository {
catalogSizesSince(day: string): CatalogSizePoint[] {
return this.db.select().from(catalogSizeHistory).where(gte(catalogSizeHistory.day, day)).orderBy(catalogSizeHistory.day).all()
}
+
+ /** Everything GET /stats returns, for the last `dayCount` UTC days. */
+ dashboard(dayCount: number, now = Date.now()): DashboardStats {
+ const days = lastDays(dayCount, now)
+ const since = days[0]!
+
+ // Daily download volume and speed, bucketed by when the transfer finished.
+ const downloadDays = this.db.all<{ day: string, bytes: number, ms: number }>(sql`
+ select substr(completed_at, 1, 10) as day,
+ sum(downloaded_bytes) as bytes,
+ sum(round((julianday(completed_at) - julianday(started_at)) * ${MS_PER_DAY})) as ms
+ from downloads
+ where completed_at >= ${since}
+ group by day`)
+ // Upload volume and speed come from receipts; only confirmed uploads have bytes.
+ const uploadDays = this.db.all<{ day: string, bytes: number, timed: number, ms: number }>(sql`
+ select substr(finished_at, 1, 10) as day,
+ sum(sent_bytes) as bytes,
+ sum(case when status = 'completed' and duration_ms > 0 then sent_bytes else 0 end) as timed,
+ sum(case when status = 'completed' and duration_ms > 0 then duration_ms else 0 end) as ms
+ from uploads
+ where finished_at >= ${since}
+ group by day`)
+ const latencyDays = this.db.all<{ day: string, seconds: number, n: number }>(sql`
+ select substr(imported_at, 1, 10) as day,
+ avg((julianday(imported_at) - julianday(completed_at)) * 86400) as seconds,
+ count(*) as n
+ from downloads
+ where imported_at >= ${since} and completed_at is not null
+ group by day`)
+
+ // Rankings group by stable ids; SQLite takes the bare `name` column from the
+ // row holding max(...), so each peer shows its latest name.
+ const gaveToMe = this.db.all<{ id: string, name: string, bytes: number, at: string }>(sql`
+ select peer_id as id, peer_name as name, sum(downloaded_bytes) as bytes, max(updated_at) as at
+ from downloads
+ group by peer_id
+ having bytes > 0
+ order by bytes desc`)
+ const tookFromMe = this.db.all<{ id: number | null, name: string, bytes: number, at: string }>(sql`
+ select peer_key_id as id, peer_name as name, sum(sent_bytes) as bytes, max(started_at) as at
+ from uploads
+ group by peer_key_id
+ having bytes > 0
+ order by bytes desc`)
+ const failures = this.db.all<{ code: string | null, n: number }>(sql`
+ select error_code as code, count(*) as n
+ from downloads
+ where status = 'failed'
+ group by error_code
+ order by n desc`)
+ const topTitles = this.db.all<{ filename: string, bytes: number, requests: number, peers: number }>(sql`
+ select filename,
+ sum(sent_bytes) as bytes,
+ count(*) as requests,
+ count(distinct coalesce(peer_key_id, -1)) as peers
+ from uploads
+ group by filename
+ having bytes > 0
+ order by bytes desc
+ limit ${TOP_TITLES}`)
+
+ const down = new Map(downloadDays.map(d => [d.day, d]))
+ const up = new Map(uploadDays.map(d => [d.day, d]))
+ const latency = new Map(latencyDays.map(d => [d.day, d]))
+
+ const sources = new Map()
+ const points = []
+ for (const p of this.catalogSizesSince(since)) {
+ // Rows come oldest first, so the newest name wins.
+ sources.set(p.sourceId, p.sourceName)
+ points.push({
+ day: p.day,
+ sourceId: p.sourceId,
+ titles: p.titles,
+ bytes: p.bytes,
+ })
+ }
+
+ const rate = (bytes: number, ms: number) => ms > 0
+ ? (bytes * 1000) / ms
+ : null
+ const ranked = (rows: { id: string | number | null, name: string, bytes: number }[]): RankedPeer[] =>
+ rows.map(r => ({
+ id: r.id == null
+ ? 'master'
+ : String(r.id),
+ name: r.name,
+ bytes: Number(r.bytes),
+ }))
+
+ return {
+ days,
+ transfers: days.map((day) => {
+ const d = down.get(day)
+ const u = up.get(day)
+ return {
+ day,
+ downloadedBytes: Number(d?.bytes ?? 0),
+ uploadedBytes: Number(u?.bytes ?? 0),
+ downloadBps: d
+ ? rate(Number(d.bytes), Number(d.ms))
+ : null,
+ uploadBps: u
+ ? rate(Number(u.timed), Number(u.ms))
+ : null,
+ }
+ }),
+ importLatency: days.map((day) => {
+ const l = latency.get(day)
+ return {
+ day,
+ avgSeconds: l
+ ? Math.round(Number(l.seconds))
+ : null,
+ count: Number(l?.n ?? 0),
+ }
+ }),
+ peers: {
+ gaveToMe: ranked(gaveToMe),
+ tookFromMe: ranked(tookFromMe),
+ },
+ failures: failures.map(f => ({
+ code: f.code ?? 'UNKNOWN_ERROR',
+ count: Number(f.n),
+ })),
+ topTitles: topTitles.map(t => ({
+ filename: t.filename,
+ bytes: Number(t.bytes),
+ requests: Number(t.requests),
+ peers: Number(t.peers),
+ })),
+ catalog: {
+ sources: [...sources].map(([id, name]) => ({ id, name })),
+ points,
+ },
+ }
+ }
}
diff --git a/apps/backend/src/modules/stats/stats.ts b/apps/backend/src/modules/stats/stats.ts
index 6dc7865..e987a6b 100644
--- a/apps/backend/src/modules/stats/stats.ts
+++ b/apps/backend/src/modules/stats/stats.ts
@@ -1,9 +1,4 @@
-import type { DownloadRecord } from '../downloads/downloads.repository'
-import type { UploadRecord } from '../uploads/uploads.repository'
-import type { CatalogSizePoint } from './stats.repository'
-
-const DAY_MS = 86_400_000
-const TOP_TITLES = 10
+// The shape of GET /stats. Built in SQL by StatsRepository.dashboard().
export interface DailyTransfer {
day: string
@@ -14,19 +9,29 @@ export interface DailyTransfer {
uploadBps: number | null
}
+/**
+ * A peer in a ranking. `id` is stable (the peer id for downloads, the API key id
+ * for uploads, `master` for the master key); `name` is its latest display name.
+ */
+export interface RankedPeer {
+ id: string
+ name: string
+ bytes: number
+}
+
export interface DashboardStats {
- /** UTC days, oldest first; every series below has one entry per day. */
+ /** UTC days, oldest first; every daily series has one entry per day. */
days: string[]
transfers: DailyTransfer[]
importLatency: { day: string, avgSeconds: number | null, count: number }[]
/** Lifetime bytes per peer, largest first. */
peers: {
- gaveToMe: { name: string, bytes: number }[]
- tookFromMe: { name: string, bytes: number }[]
+ gaveToMe: RankedPeer[]
+ tookFromMe: RankedPeer[]
}
/** Downloads that are failed right now, by error code, most common first. */
failures: { code: string, count: number }[]
- /** Files peers pulled most from this instance, by bytes sent. */
+ /** Files peers pulled most from this instance, by confirmed bytes sent. */
topTitles: { filename: string, bytes: number, requests: number, peers: number }[]
catalog: {
sources: { id: string, name: string }[]
@@ -34,155 +39,9 @@ export interface DashboardStats {
}
}
-function dayOf(iso: string): string {
- return iso.slice(0, 10)
-}
-
-function durationMs(from: string, to: string): number {
- return Math.max(0, Date.parse(to) - Date.parse(from))
-}
-
-function ranked(totals: Map): { name: string, bytes: number }[] {
- return [...totals].map(([name, bytes]) => ({ name, bytes })).filter(p => p.bytes > 0).sort((a, b) => b.bytes - a.bytes)
-}
-
-function add(map: Map, key: string, value: number): void {
- map.set(key, (map.get(key) ?? 0) + value)
-}
-
-/** Everything the dashboard charts need, computed from the stored rows. */
-export function buildStats(
- downloads: DownloadRecord[],
- uploads: UploadRecord[],
- catalogPoints: CatalogSizePoint[],
- dayCount: number,
- now = Date.now(),
-): DashboardStats {
- const days = Array.from({ length: dayCount }, (_, i) => dayOf(new Date(now - (dayCount - 1 - i) * DAY_MS).toISOString()))
- const inRange = new Set(days)
- const buckets = new Map(days.map(day => [
- day,
- {
- down: 0,
- up: 0,
- downTimed: 0,
- downMs: 0,
- upTimed: 0,
- upMs: 0,
- latencyMs: 0,
- latencyCount: 0,
- },
- ]))
-
- const gaveToMe = new Map()
- const failures = new Map()
- for (const d of downloads) {
- add(gaveToMe, d.peerName, d.downloadedBytes)
- if (d.status === 'failed')
- add(failures, d.errorCode ?? 'UNKNOWN_ERROR', 1)
- if (!d.completedAt)
- continue
- const bucket = buckets.get(dayOf(d.completedAt))
- if (bucket) {
- bucket.down += d.downloadedBytes
- bucket.downTimed += d.downloadedBytes
- bucket.downMs += durationMs(d.startedAt, d.completedAt)
- }
- if (!d.importedAt)
- continue
- const imported = buckets.get(dayOf(d.importedAt))
- if (imported) {
- imported.latencyMs += durationMs(d.completedAt, d.importedAt)
- imported.latencyCount++
- }
- }
-
- const tookFromMe = new Map()
- const titles = new Map }>()
- for (const u of uploads) {
- add(tookFromMe, u.peerName, u.sentBytes)
- const title = titles.get(u.filename) ?? {
- bytes: 0,
- requests: 0,
- peers: new Set(),
- }
- title.bytes += u.sentBytes
- title.requests++
- title.peers.add(u.peerName)
- titles.set(u.filename, title)
- if (!u.finishedAt)
- continue
- const bucket = buckets.get(dayOf(u.finishedAt))
- if (!bucket)
- continue
- // Bytes and time come from the downloading jack's receipt, so only confirmed
- // uploads count; files go out zero-copy and jack never measures them itself.
- bucket.up += u.sentBytes
- if (u.status === 'completed' && u.durationMs) {
- bucket.upTimed += u.sentBytes
- bucket.upMs += u.durationMs
- }
- }
-
- const sources = new Map()
- const points = []
- for (const p of catalogPoints) {
- if (!inRange.has(p.day))
- continue
- // The newest name wins, so a renamed peer shows its current name.
- sources.set(p.sourceId, p.sourceName)
- points.push({
- day: p.day,
- sourceId: p.sourceId,
- titles: p.titles,
- bytes: p.bytes,
- })
- }
+const DAY_MS = 86_400_000
- return {
- days,
- transfers: days.map((day) => {
- const b = buckets.get(day)!
- return {
- day,
- downloadedBytes: b.down,
- uploadedBytes: b.up,
- downloadBps: b.downMs > 0
- ? (b.downTimed * 1000) / b.downMs
- : null,
- uploadBps: b.upMs > 0
- ? (b.upTimed * 1000) / b.upMs
- : null,
- }
- }),
- importLatency: days.map((day) => {
- const b = buckets.get(day)!
- return {
- day,
- avgSeconds: b.latencyCount > 0
- ? Math.round(b.latencyMs / b.latencyCount / 1000)
- : null,
- count: b.latencyCount,
- }
- }),
- peers: {
- gaveToMe: ranked(gaveToMe),
- tookFromMe: ranked(tookFromMe),
- },
- failures: [...failures].map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count),
- topTitles: [...titles]
- .map(([filename, t]) => ({
- filename,
- bytes: t.bytes,
- requests: t.requests,
- peers: t.peers.size,
- }))
- .filter(t => t.bytes > 0)
- .sort((a, b) => b.bytes - a.bytes)
- .slice(0, TOP_TITLES),
- catalog: {
- sources: [...sources].map(([id, name]) => ({ id, name })),
- points,
- },
- }
+/** The last `dayCount` UTC days ending today, oldest first. */
+export function lastDays(dayCount: number, now = Date.now()): string[] {
+ return Array.from({ length: dayCount }, (_, i) => new Date(now - (dayCount - 1 - i) * DAY_MS).toISOString().slice(0, 10))
}
diff --git a/apps/backend/src/modules/status/status.controller.ts b/apps/backend/src/modules/status/status.controller.ts
index 8fa3532..f10abd8 100644
--- a/apps/backend/src/modules/status/status.controller.ts
+++ b/apps/backend/src/modules/status/status.controller.ts
@@ -4,12 +4,13 @@ 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 { DashboardStats } from '../stats/stats'
import type { StatsRepository } from '../stats/stats.repository'
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'
-import { buildStats } from '../stats/stats'
+import { lastDays } from '../stats/stats'
// 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
@@ -217,14 +218,17 @@ export class StatusController {
}
/** Chart data for the dashboard: the last `days` UTC days plus lifetime rankings. */
- getStats(days: number) {
- const since = new Date(Date.now() - (days - 1) * 86_400_000).toISOString().slice(0, 10)
- return buildStats(
- this.downloads?.list() ?? [],
- this.uploads?.list() ?? [],
- this.stats?.catalogSizesSince(since) ?? [],
- days,
- )
+ getStats(days: number): DashboardStats {
+ // Aggregated in SQLite: the history is never loaded for a chart.
+ return this.stats?.dashboard(days) ?? {
+ days: lastDays(days),
+ transfers: [],
+ importLatency: [],
+ peers: { gaveToMe: [], tookFromMe: [] },
+ failures: [],
+ topTitles: [],
+ catalog: { sources: [], points: [] },
+ }
}
listUploads() {
diff --git a/apps/ui/app/components/DashSection.vue b/apps/ui/app/components/DashSection.vue
index 4b13cd2..c995d90 100644
--- a/apps/ui/app/components/DashSection.vue
+++ b/apps/ui/app/components/DashSection.vue
@@ -8,15 +8,18 @@ const open = defineModel('open', { default: true })
-
-
- {{ title }}
-
+
+
+
+
+ {{ title }}
+
+
diff --git a/apps/ui/app/components/LineChart.vue b/apps/ui/app/components/LineChart.vue
index d809c37..343212f 100644
--- a/apps/ui/app/components/LineChart.vue
+++ b/apps/ui/app/components/LineChart.vue
@@ -68,6 +68,23 @@ function onMove(event: PointerEvent) {
const ratio = (event.clientX - box.left - PAD) / Math.max(1, box.width - 2 * PAD)
hover.value = Math.min(props.days.length - 1, Math.max(0, Math.round(ratio * (props.days.length - 1))))
}
+
+// Keyboard users read the chart day by day: focus lands on the latest day, the
+// arrows step through, Home/End jump to either edge. The tooltip is the readout.
+function onKey(event: KeyboardEvent) {
+ const last = props.days.length - 1
+ const current = hover.value ?? last
+ const next = {
+ ArrowLeft: current - 1,
+ ArrowRight: current + 1,
+ Home: 0,
+ End: last,
+ }[event.key]
+ if (next === undefined)
+ return
+ event.preventDefault()
+ hover.value = Math.min(last, Math.max(0, next))
+}
@@ -86,9 +103,15 @@ function onMove(event: PointerEvent) {
diff --git a/apps/ui/app/components/RankBars.vue b/apps/ui/app/components/RankBars.vue
index cc0267e..2b09c52 100644
--- a/apps/ui/app/components/RankBars.vue
+++ b/apps/ui/app/components/RankBars.vue
@@ -1,6 +1,7 @@
-
-
-
-
-
-
- {{ s.name }}
-
-
-
-
-
- {{ format(max) }}
- 0
-
-
-
-
- {{ formatDay(days[0]!) }}
- {{ formatDay(days.at(-1)!) }}
-
-
-
-
-
- Day
-
- {{ s.name }}
-
-
-
-
-
- {{ formatDay(day) }}
-
- {{ format(s.values[i] ?? 0) }}
-
-
-
-
-
-
From be5b88a2ebc2453f752fa94aa9b355d9fb2b9ca8 Mon Sep 17 00:00:00 2001
From: Lucas Santos
Date: Mon, 9 Nov 2026 22:24:06 +0100
Subject: [PATCH 22/28] feat(ui): add a history range picker and mirrored
activity charts to the dashboard
---
apps/ui/app/components/LineChart.vue | 52 +++----
apps/ui/app/components/MirrorChart.vue | 189 +++++++++++++++++++++++++
apps/ui/app/components/StackColumn.vue | 152 ++++++++++++++++++++
apps/ui/app/pages/index.vue | 106 ++++++++++----
apps/ui/app/types/management.ts | 16 ++-
apps/ui/app/utils/format.ts | 22 +++
6 files changed, 483 insertions(+), 54 deletions(-)
create mode 100644 apps/ui/app/components/MirrorChart.vue
create mode 100644 apps/ui/app/components/StackColumn.vue
diff --git a/apps/ui/app/components/LineChart.vue b/apps/ui/app/components/LineChart.vue
index b3e0cf2..68f870d 100644
--- a/apps/ui/app/components/LineChart.vue
+++ b/apps/ui/app/components/LineChart.vue
@@ -3,26 +3,30 @@ export interface LineSeries {
name: string
/** A CSS color, e.g. `var(--viz-1)`. */
color: string
- /** One per day; null leaves a gap in the line. */
+ /** One per label; null leaves a gap in the line. */
values: (number | null)[]
}
const props = defineProps<{
- days: string[]
+ /** One per point, oldest first, already formatted for display. */
+ labels: string[]
series: LineSeries[]
format: (value: number) => string
}>()
-const HEIGHT = 160
const PAD = 6
-// Drawn at the real pixel width so lines and dots keep their shape.
+// Drawn at the real pixel size so lines and dots keep their shape. The plot
+// fills the height its container gives it (at least 160px), so it can match a
+// taller chart next to it.
const plot = ref()
const width = ref(600)
+const HEIGHT = ref(160)
let observer: ResizeObserver | undefined
onMounted(() => {
observer = new ResizeObserver(([entry]) => {
width.value = Math.max(1, entry!.contentRect.width)
+ HEIGHT.value = Math.max(160, entry!.contentRect.height)
})
observer.observe(plot.value!)
})
@@ -31,15 +35,15 @@ onBeforeUnmount(() => observer?.disconnect())
const max = computed(() => Math.max(0, ...props.series.flatMap(s => s.values.filter((v): v is number => v != null))))
function x(i: number): number {
- return props.days.length > 1
- ? PAD + (i / (props.days.length - 1)) * (width.value - 2 * PAD)
+ return props.labels.length > 1
+ ? PAD + (i / (props.labels.length - 1)) * (width.value - 2 * PAD)
: width.value / 2
}
function y(v: number): number {
return max.value > 0
- ? HEIGHT - PAD - (v / max.value) * (HEIGHT - 2 * PAD)
- : HEIGHT - PAD
+ ? HEIGHT.value - PAD - (v / max.value) * (HEIGHT.value - 2 * PAD)
+ : HEIGHT.value - PAD
}
// A null value breaks the line; each run of values is its own subpath.
@@ -66,13 +70,13 @@ const hover = ref(null)
function onMove(event: PointerEvent) {
const box = plot.value!.getBoundingClientRect()
const ratio = (event.clientX - box.left - PAD) / Math.max(1, box.width - 2 * PAD)
- hover.value = Math.min(props.days.length - 1, Math.max(0, Math.round(ratio * (props.days.length - 1))))
+ hover.value = Math.min(props.labels.length - 1, Math.max(0, Math.round(ratio * (props.labels.length - 1))))
}
-// Keyboard users read the chart day by day: focus lands on the latest day, the
+// Keyboard users read the chart point by point: focus lands on the latest one, the
// arrows step through, Home/End jump to either edge. The tooltip is the readout.
function onKey(event: KeyboardEvent) {
- const last = props.days.length - 1
+ const last = props.labels.length - 1
const current = hover.value ?? last
const positions: Record = {
ArrowLeft: current - 1,
@@ -89,7 +93,7 @@ function onKey(event: KeyboardEvent) {
-
+
@@ -97,24 +101,24 @@ function onKey(event: KeyboardEvent) {
-
-
+
+
{{ format(max) }}
0
-
+
- {{ formatDay(days[hover]!) }}
+ {{ labels[hover] }}
@@ -159,22 +163,22 @@ function onKey(event: KeyboardEvent) {
- {{ formatDay(days[0]!) }}
- {{ formatDay(days.at(-1)!) }}
+ {{ labels[0] }}
+ {{ labels.at(-1) }}
- Day
+ Time
{{ s.name }}
-
- {{ formatDay(day) }}
+
+ {{ label }}
{{ s.values[i] == null ? 'no data' : format(s.values[i]!) }}
diff --git a/apps/ui/app/components/MirrorChart.vue b/apps/ui/app/components/MirrorChart.vue
new file mode 100644
index 0000000..77efd93
--- /dev/null
+++ b/apps/ui/app/components/MirrorChart.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+ {{ above.name }}
+
+
+
+ {{ below.name }}
+
+
+
+
+
+ {{ format(max) }}
+ 0
+ {{ format(max) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ labels[hover] }}
+
+
+
+ {{ s.name }}
+ {{ s.values[hover] == null ? '—' : format(s.values[hover]!) }}
+
+
+
+
+
+ {{ labels[0] }}
+ {{ labels.at(-1) }}
+
+
+
+
+
+ Time
+
+ {{ s.name }}
+
+
+
+
+
+ {{ label }}
+
+ {{ s.values[i] == null ? 'no data' : format(s.values[i]!) }}
+
+
+
+
+
+
diff --git a/apps/ui/app/components/StackColumn.vue b/apps/ui/app/components/StackColumn.vue
new file mode 100644
index 0000000..7622ddf
--- /dev/null
+++ b/apps/ui/app/components/StackColumn.vue
@@ -0,0 +1,152 @@
+
+
+
+
+ {{ empty }}
+
+
+
+
+
+
+
+ {{ s.label }}: {{ format(s.value) }}
+
+
+
+ {{ s.label }}
+ {{ format(s.value) }}
+
+
+
+
+
+ {{ s.label }}: {{ format(s.value) }}
+
+
+
+ {{ s.label }}
+ {{ format(s.value) }}
+
+
+
+
+
+
+
+ Error code
+ Failed downloads
+
+
+
+
+ {{ item.label }}
+ {{ format(item.value) }}
+
+
+
+
+
diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue
index 8e7213f..e0210d9 100644
--- a/apps/ui/app/pages/index.vue
+++ b/apps/ui/app/pages/index.vue
@@ -1,6 +1,7 @@