feat(downloads): add task actions and classification

This commit is contained in:
saltbo
2026-06-04 14:08:39 -04:00
parent 9f9d895c73
commit 94dada75ba
16 changed files with 4555 additions and 46 deletions
@@ -0,0 +1,2 @@
ALTER TABLE `download_tasks` ADD `category` text;--> statement-breakpoint
ALTER TABLE `download_tasks` ADD `tags` text DEFAULT '[]' NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -260,6 +260,13 @@
"when": 1780502607046,
"tag": "0037_add-download-task-upload-progress",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1780596348411,
"tag": "0038_add-download-task-classification",
"breakpoints": true
}
]
}
+2
View File
@@ -336,6 +336,8 @@ export const downloadTasks = sqliteTable(
sourceUri: text('source_uri').notNull(),
name: text('name'),
targetFolder: text('target_folder').notNull().default(''),
category: text('category'),
tags: text('tags').notNull().default('[]'),
assignedDownloaderId: text('assigned_downloader_id'),
status: text('status').notNull(),
downloadedBytes: integer('downloaded_bytes').notNull().default(0),
@@ -167,6 +167,8 @@ describe('Download tasks API integration', () => {
source: { type: 'http', uri: 'https://example.com/fixture.txt' },
targetFolder: 'Remote Downloads',
name: 'fixture.txt',
category: 'fixtures',
tags: ['sample', 'http'],
}),
})
expect(createTaskRes.status).toBe(201)
@@ -174,21 +176,27 @@ describe('Download tasks API integration', () => {
id: string
assignedDownloaderId: string
status: string
category: string
tags: string[]
uploadToken?: string
}
expect(createdTask.status).toBe('assigned')
expect(createdTask.assignedDownloaderId).toBe(createdDownloader.downloader.id)
expect(createdTask.category).toBe('fixtures')
expect(createdTask.tags).toEqual(['sample', 'http'])
expect(createdTask.uploadToken).toBeUndefined()
const assignedRes = await app.request('/api/download-tasks?assignedTo=me', {
const assignedRes = await app.request('/api/download-tasks?assignedTo=me&category=fixtures&tag=http', {
headers: { Authorization: `Bearer ${createdDownloader.token}` },
})
expect(assignedRes.status).toBe(200)
const assigned = (await assignedRes.json()) as {
items: Array<{ id: string; uploadToken?: string; status: string }>
items: Array<{ id: string; uploadToken?: string; status: string; category: string; tags: string[] }>
}
const assignedTask = assigned.items.find((item) => item.id === createdTask.id)
expect(assignedTask?.status).toBe('assigned')
expect(assignedTask?.category).toBe('fixtures')
expect(assignedTask?.tags).toEqual(['sample', 'http'])
expect(assignedTask?.uploadToken).toBeTruthy()
const uploadHeaders = {
Authorization: `Bearer ${assignedTask?.uploadToken}`,
@@ -351,4 +359,109 @@ describe('Download tasks API integration', () => {
expect(task.downloadedBytes).toBe(10 * 1024 * 1024)
expect(task.storageUploadedBytes).toBe(10 * 1024 * 1024)
})
it('submits user task actions through downloader polling state', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'action-downloader')
const downloaderHeaders = {
Authorization: `Bearer ${createdDownloader.token}`,
'Content-Type': 'application/json',
}
const heartbeatRes = await app.request('/api/downloader/heartbeat', {
method: 'POST',
headers: downloaderHeaders,
body: JSON.stringify({ ...heartbeat, currentTasks: 0 }),
})
expect(heartbeatRes.status).toBe(200)
const user = await authedHeaders(app, 'download-actions-user@example.com')
const createTaskRes = await app.request('/api/download-tasks', {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: { type: 'http', uri: 'https://example.com/actions.bin' },
targetFolder: '',
}),
})
expect(createTaskRes.status).toBe(201)
const createdTask = (await createTaskRes.json()) as { id: string; status: string; assignedDownloaderId: string }
expect(createdTask.status).toBe('assigned')
expect(createdTask.assignedDownloaderId).toBe(createdDownloader.downloader.id)
const pauseRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'pause' }),
})
expect(pauseRes.status).toBe(200)
await expect(pauseRes.json()).resolves.toMatchObject({ status: 'paused' })
const pausedAssignedRes = await app.request('/api/download-tasks?assignedTo=me', {
headers: { Authorization: `Bearer ${createdDownloader.token}` },
})
expect(pausedAssignedRes.status).toBe(200)
const pausedAssigned = (await pausedAssignedRes.json()) as { items: Array<{ id: string; status: string }> }
expect(pausedAssigned.items.find((item) => item.id === createdTask.id)?.status).toBe('paused')
const resumeRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'resume' }),
})
expect(resumeRes.status).toBe(200)
await expect(resumeRes.json()).resolves.toMatchObject({ status: 'assigned' })
const cancelRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'cancel' }),
})
expect(cancelRes.status).toBe(200)
await expect(cancelRes.json()).resolves.toMatchObject({ status: 'canceled' })
const canceledAssignedRes = await app.request('/api/download-tasks?assignedTo=me', {
headers: { Authorization: `Bearer ${createdDownloader.token}` },
})
expect(canceledAssignedRes.status).toBe(200)
const canceledAssigned = (await canceledAssignedRes.json()) as { items: Array<{ id: string; status: string }> }
expect(canceledAssigned.items.find((item) => item.id === createdTask.id)?.status).toBe('canceled')
const deleteRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete' }),
})
expect(deleteRes.status).toBe(200)
await expect(deleteRes.json()).resolves.toEqual({ id: createdTask.id, deleted: true })
})
it('rejects invalid task actions', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const user = await authedHeaders(app, 'invalid-download-actions-user@example.com')
const createTaskRes = await app.request('/api/download-tasks', {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: { type: 'http', uri: 'https://example.com/no-downloader.bin' },
targetFolder: '',
}),
})
expect(createTaskRes.status).toBe(201)
const createdTask = (await createTaskRes.json()) as { id: string; status: string }
expect(createdTask.status).toBe('queued')
const deleteRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete' }),
})
expect(deleteRes.status).toBe(409)
await expect(deleteRes.json()).resolves.toMatchObject({
error: 'Only completed, failed, or canceled tasks can be deleted',
})
})
})
+57 -3
View File
@@ -1,6 +1,7 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import {
createDownloadTaskSchema,
downloadTaskActionInputSchema,
downloadTaskDetailSchema,
listDownloadTasksQuerySchema,
updateDownloadTaskSchema,
@@ -13,6 +14,7 @@ import {
DownloadError,
getDownloadTask,
listDownloadTasks,
performDownloadTaskAction,
updateDownloadTask,
} from '../services/downloads'
@@ -38,7 +40,19 @@ const downloadTaskSchema = z.object({
sourceUri: z.string(),
name: z.string(),
targetFolder: z.string(),
status: z.enum(['queued', 'assigned', 'running', 'billing_paused', 'uploading', 'completed', 'failed', 'canceled']),
category: z.string().nullable(),
tags: z.array(z.string()),
status: z.enum([
'queued',
'assigned',
'running',
'billing_paused',
'paused',
'uploading',
'completed',
'failed',
'canceled',
]),
downloadedBytes: int64Schema(),
storageUploadedBytes: int64Schema(),
totalBytes: nullableInt64Schema(),
@@ -93,6 +107,7 @@ const eventsRoute = createRoute({
method: 'get',
path: '/events',
middleware: [requirePermission('remoteDownload', 'read')] as const,
request: { query: listDownloadTasksQuerySchema },
responses: {
200: {
content: { 'text/event-stream': { schema: z.string() } },
@@ -130,6 +145,26 @@ const updateRoute = createRoute({
},
})
const actionRoute = createRoute({
method: 'post',
path: '/{id}/actions',
middleware: [requirePermission('remoteDownload', 'cancel')] as const,
request: {
params: z.object({ id: z.string() }),
body: { content: { 'application/json': { schema: downloadTaskActionInputSchema } }, required: true },
},
responses: {
200: jsonResponse(
z.union([downloadTaskSchema, z.object({ id: z.string(), deleted: z.literal(true) })]),
'Task action result',
),
401: jsonResponse(errorSchema, 'Unauthorized'),
403: jsonResponse(errorSchema, 'Forbidden'),
404: jsonResponse(errorSchema, 'Not found'),
409: jsonResponse(errorSchema, 'Invalid task state'),
},
})
const downloadTasksRoute = new OpenAPIHono<Env>()
.openapi(listRoute, (async (c: OpenAPIContext) => {
const principal = c.get('principal')
@@ -139,6 +174,8 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
const result = await listDownloadTasks(c.get('platform'), {
downloaderId: principal.downloaderId,
status: query.status,
category: query.category,
tag: query.tag,
page: query.page,
pageSize: query.pageSize,
includeUploadToken: true,
@@ -151,6 +188,8 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
const result = await listDownloadTasks(c.get('platform'), {
orgId,
status: query.status,
category: query.category,
tag: query.tag,
page: query.page,
pageSize: query.pageSize,
})
@@ -176,6 +215,7 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
.openapi(eventsRoute, (async (c: OpenAPIContext) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
const query = c.req.valid('query') as z.infer<typeof listDownloadTasksQuerySchema>
const signal = c.req.raw.signal
let closed = false
let lastFingerprint = ''
@@ -188,11 +228,18 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
const tick = async () => {
if (closed) return
try {
const result = await listDownloadTasks(c.get('platform'), { orgId, page: 1, pageSize: 50 })
const result = await listDownloadTasks(c.get('platform'), {
orgId,
status: query.status,
category: query.category,
tag: query.tag,
page: query.page,
pageSize: query.pageSize,
})
const fingerprint = result.items.map((task) => `${task.id}:${task.updatedAt}`).join('|')
if (fingerprint !== lastFingerprint) {
lastFingerprint = fingerprint
send('snapshot', { items: result.items, total: result.total, page: 1, pageSize: 50 })
send('snapshot', { items: result.items, total: result.total, page: query.page, pageSize: query.pageSize })
} else {
send('heartbeat', { at: new Date().toISOString() })
}
@@ -225,6 +272,13 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
const id = c.req.param('id') as string
return downloadTaskResponse(c, async () => getDownloadTask(c.get('platform'), orgId, id))
}) as never)
.openapi(actionRoute, (async (c: OpenAPIContext) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
const id = c.req.param('id') as string
const { action } = c.req.valid('json') as z.infer<typeof downloadTaskActionInputSchema>
return downloadTaskResponse(c, async () => performDownloadTaskAction(c.get('platform'), orgId, id, action))
}) as never)
.openapi(updateRoute, (async (c: OpenAPIContext) => {
const principal = c.get('principal')
const id = c.req.param('id') as string
+122 -3
View File
@@ -2,11 +2,12 @@ import type {
CreateDownloaderInput,
CreateDownloadTaskInput,
DownloaderHeartbeatInput,
DownloadTaskActionInput,
UpdateDownloaderInput,
UpdateDownloadTaskInput,
} from '@shared/schemas'
import type { Downloader, DownloadTask } from '@shared/types'
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'
import { and, asc, count, desc, eq, inArray, like } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { downloaders, downloadTasks } from '../db/schema'
import type { Platform } from '../platform/interface'
@@ -15,6 +16,8 @@ import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './r
const DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES = 100 * 1024 * 1024
const UPLOAD_TOKEN_TTL_SECONDS = 24 * 60 * 60
const ACTIVE_TASK_STATUSES = ['queued', 'assigned', 'running', 'billing_paused', 'uploading'] as const
const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'canceled'] as const
export class DownloadError extends Error {
constructor(
@@ -25,7 +28,7 @@ export class DownloadError extends Error {
| 'invalid_state'
| 'billing_paused'
| 'unsupported_source',
message = code,
message: string = code,
) {
super(message)
this.name = 'DownloadError'
@@ -139,7 +142,7 @@ export async function deleteDownloader(platform: Platform, id: string): Promise<
.where(
and(
eq(downloadTasks.assignedDownloaderId, id),
inArray(downloadTasks.status, ['queued', 'assigned', 'running', 'billing_paused', 'uploading']),
inArray(downloadTasks.status, [...ACTIVE_TASK_STATUSES, 'paused']),
),
)
await platform.db.delete(downloaders).where(eq(downloaders.id, id))
@@ -207,6 +210,8 @@ export async function createDownloadTask(
sourceUri: input.source.uri,
name: input.name ?? null,
targetFolder: input.targetFolder,
category: input.category ?? null,
tags: JSON.stringify(input.tags ?? []),
assignedDownloaderId: assigned?.id ?? null,
status: assigned ? 'assigned' : 'queued',
uploadTokenHash: uploadToken?.hash ?? null,
@@ -225,6 +230,8 @@ export async function listDownloadTasks(
orgId?: string
downloaderId?: string
status?: string
category?: string
tag?: string
page: number
pageSize: number
includeUploadToken?: boolean
@@ -235,6 +242,8 @@ export async function listDownloadTasks(
if (opts.orgId) filters.push(eq(downloadTasks.orgId, opts.orgId))
if (opts.downloaderId) filters.push(eq(downloadTasks.assignedDownloaderId, opts.downloaderId))
if (opts.status) filters.push(eq(downloadTasks.status, opts.status))
if (opts.category) filters.push(eq(downloadTasks.category, opts.category))
if (opts.tag) filters.push(like(downloadTasks.tags, `%${JSON.stringify(opts.tag)}%`))
const where = filters.length ? and(...filters) : undefined
const [rows, totalRows] = await Promise.all([
platform.db
@@ -371,6 +380,105 @@ export async function updateDownloadTask(
return getDownloadTask(platform, task.orgId, id)
}
export async function performDownloadTaskAction(
platform: Platform,
orgId: string,
id: string,
action: DownloadTaskActionInput['action'],
): Promise<DownloadTask | { id: string; deleted: true }> {
const rows = await platform.db
.select()
.from(downloadTasks)
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId)))
.limit(1)
const task = rows[0]
if (!task) throw new DownloadError('not_found')
if (action === 'delete') {
if (!TERMINAL_TASK_STATUSES.includes(task.status as (typeof TERMINAL_TASK_STATUSES)[number])) {
throw new DownloadError('invalid_state', 'Only completed, failed, or canceled tasks can be deleted')
}
await platform.db.delete(downloadTasks).where(eq(downloadTasks.id, id))
return { id, deleted: true }
}
const now = new Date()
if (action === 'pause') {
if (task.status === 'paused') return toDownloadTask(task)
if (!ACTIVE_TASK_STATUSES.includes(task.status as (typeof ACTIVE_TASK_STATUSES)[number])) {
throw new DownloadError('invalid_state', 'Only active tasks can be paused')
}
await platform.db
.update(downloadTasks)
.set({ status: 'paused', downloadBps: 0, uploadBps: 0, updatedAt: now })
.where(eq(downloadTasks.id, id))
return getDownloadTask(platform, orgId, id)
}
if (action === 'resume') {
if (task.status !== 'paused') throw new DownloadError('invalid_state', 'Only paused tasks can be resumed')
await platform.db
.update(downloadTasks)
.set({
status: task.assignedDownloaderId ? 'assigned' : 'queued',
downloadBps: 0,
uploadBps: 0,
updatedAt: now,
})
.where(eq(downloadTasks.id, id))
if (!task.assignedDownloaderId) await assignQueuedTasks(platform)
return getDownloadTask(platform, orgId, id)
}
if (action === 'cancel') {
if (task.status === 'canceled') return toDownloadTask(task)
if (task.status === 'completed') throw new DownloadError('invalid_state', 'Completed tasks cannot be canceled')
await platform.db
.update(downloadTasks)
.set({
status: 'canceled',
downloadBps: 0,
uploadBps: 0,
finishedAt: task.finishedAt ?? now,
updatedAt: now,
})
.where(eq(downloadTasks.id, id))
return getDownloadTask(platform, orgId, id)
}
if (action === 'retry') {
if (!['failed', 'canceled'].includes(task.status)) {
throw new DownloadError('invalid_state', 'Only failed or canceled tasks can be retried')
}
await platform.db
.update(downloadTasks)
.set({
status: 'queued',
assignedDownloaderId: null,
uploadTokenHash: null,
uploadTokenJti: null,
uploadTokenExpiresAt: null,
downloadedBytes: 0,
uploadedBytes: 0,
totalBytes: null,
downloadBps: 0,
uploadBps: 0,
errorMessage: null,
resultObjectId: null,
detail: null,
assignedAt: null,
startedAt: null,
finishedAt: null,
updatedAt: now,
})
.where(eq(downloadTasks.id, id))
await assignQueuedTasks(platform)
return getDownloadTask(platform, orgId, id)
}
throw new DownloadError('invalid_state')
}
export async function assertTaskUploadAllowed(platform: Platform, params: { taskId: string; downloaderId: string }) {
const rows = await platform.db.select().from(downloadTasks).where(eq(downloadTasks.id, params.taskId)).limit(1)
const task = rows[0]
@@ -511,6 +619,8 @@ function toDownloadTask(row: DownloadTaskRow): DownloadTask {
sourceUri: row.sourceUri,
name: row.name,
targetFolder: row.targetFolder,
category: row.category,
tags: parseTaskTags(row.tags),
assignedDownloaderId: row.assignedDownloaderId,
status: row.status as DownloadTask['status'],
downloadedBytes: row.downloadedBytes,
@@ -547,6 +657,15 @@ function parseTaskDetail(value: string | null): DownloadTask['detail'] {
}
}
function parseTaskTags(value: string): string[] {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
} catch {
return []
}
}
function parseCapabilities(value: string): string[] {
try {
const parsed = JSON.parse(value)
+2
View File
@@ -425,6 +425,8 @@ const APP_SCHEMA_SQL = `
source_uri TEXT NOT NULL,
name TEXT,
target_folder TEXT NOT NULL DEFAULT '',
category TEXT,
tags TEXT NOT NULL DEFAULT '[]',
assigned_downloader_id TEXT,
status TEXT NOT NULL,
downloaded_bytes INTEGER NOT NULL DEFAULT 0,
+13
View File
@@ -7,11 +7,13 @@ export const downloadTaskStatusSchema = z.enum([
'assigned',
'running',
'billing_paused',
'paused',
'uploading',
'completed',
'failed',
'canceled',
])
export const downloadTaskActionSchema = z.enum(['pause', 'resume', 'cancel', 'retry', 'delete'])
export const downloadSourceTypeSchema = z.enum(['http', 'magnet', 'torrent_url'])
export const downloadTaskPhaseSchema = z.enum(['metadata', 'downloading', 'uploading', 'seeding', 'completed', 'error'])
@@ -86,6 +88,8 @@ export const createDownloaderSchema = z.object({
})
const downloadUriSchema = z.string().min(1).max(4096)
const downloadTaskCategorySchema = z.string().trim().min(1).max(120)
const downloadTaskTagsSchema = z.array(z.string().trim().min(1).max(80)).max(20)
export const createDownloadTaskSchema = z.object({
source: z.object({
@@ -94,6 +98,8 @@ export const createDownloadTaskSchema = z.object({
}),
targetFolder: z.string(),
name: z.string().min(1).max(255).optional(),
category: downloadTaskCategorySchema.optional(),
tags: downloadTaskTagsSchema.optional(),
})
export const updateDownloadTaskSchema = z.object({
@@ -108,9 +114,15 @@ export const updateDownloadTaskSchema = z.object({
detail: downloadTaskDetailSchema.nullable().optional(),
})
export const downloadTaskActionInputSchema = z.object({
action: downloadTaskActionSchema,
})
export const listDownloadTasksQuerySchema = z.object({
status: downloadTaskStatusSchema.optional(),
assignedTo: z.enum(['me']).optional(),
category: z.string().trim().min(1).max(120).optional(),
tag: z.string().trim().min(1).max(80).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
@@ -152,6 +164,7 @@ export type UpdateDownloaderInput = z.infer<typeof updateDownloaderSchema>
export type CreateDownloaderInput = z.infer<typeof createDownloaderSchema>
export type CreateDownloadTaskInput = z.infer<typeof createDownloadTaskSchema>
export type UpdateDownloadTaskInput = z.infer<typeof updateDownloadTaskSchema>
export type DownloadTaskActionInput = z.infer<typeof downloadTaskActionInputSchema>
export type ListDownloadTasksQuery = z.infer<typeof listDownloadTasksQuerySchema>
export type DownloadTaskDetail = z.infer<typeof downloadTaskDetailSchema>
export type CreateObjectUploadSessionInput = z.infer<typeof createObjectUploadSessionSchema>
+3
View File
@@ -76,6 +76,7 @@ export type {
CreateDownloadTaskInput,
CreateObjectUploadSessionInput,
DownloaderHeartbeatInput,
DownloadTaskActionInput,
DownloadTaskDetail,
ListDownloadTasksQuery,
PatchObjectUploadSessionInput,
@@ -91,6 +92,8 @@ export {
downloaderHeartbeatSchema,
downloaderStatusSchema,
downloadSourceTypeSchema,
downloadTaskActionInputSchema,
downloadTaskActionSchema,
downloadTaskDetailSchema,
downloadTaskStatusSchema,
listDownloadTasksQuerySchema,
+5
View File
@@ -236,11 +236,14 @@ export type DownloadTaskStatus =
| 'assigned'
| 'running'
| 'billing_paused'
| 'paused'
| 'uploading'
| 'completed'
| 'failed'
| 'canceled'
export type DownloadTaskAction = 'pause' | 'resume' | 'cancel' | 'retry' | 'delete'
export interface DownloadTask {
id: string
orgId: string
@@ -249,6 +252,8 @@ export interface DownloadTask {
sourceUri: string
name: string | null
targetFolder: string
category: string | null
tags: string[]
assignedDownloaderId: string | null
status: DownloadTaskStatus
downloadedBytes: number
+17
View File
@@ -125,11 +125,19 @@
"downloads.targetFolderRoot": "Root",
"downloads.name": "File name",
"downloads.namePlaceholder": "Optional",
"downloads.category": "Category",
"downloads.categoryPlaceholder": "Movies, docs, backups",
"downloads.tags": "Tags",
"downloads.tagsPlaceholder": "Comma separated, e.g. 4K, private",
"downloads.filters": "Filter download tasks",
"downloads.filterCategory": "Filter by category",
"downloads.filterTag": "Filter by tag",
"downloads.create": "Create",
"downloads.createTitle": "New remote download",
"downloads.start": "Start download",
"downloads.createSuccess": "Download task created",
"downloads.cancelSuccess": "Download task canceled",
"downloads.actionSuccess": "Task action submitted",
"downloads.cancel": "Cancel",
"downloads.resizePanel": "Resize download list and detail panel",
"downloads.empty": "No remote download tasks",
@@ -146,11 +154,17 @@
"downloads.status.assigned": "Assigned",
"downloads.status.running": "Running",
"downloads.status.billing_paused": "Billing paused",
"downloads.status.paused": "Paused",
"downloads.status.uploading": "Uploading",
"downloads.status.completed": "Completed",
"downloads.status.seeding": "Seeding",
"downloads.status.failed": "Failed",
"downloads.status.canceled": "Canceled",
"downloads.actions.pause": "Pause",
"downloads.actions.resume": "Resume",
"downloads.actions.cancel": "Cancel",
"downloads.actions.retry": "Retry",
"downloads.actions.delete": "Delete",
"downloads.phase.metadata": "Metadata",
"downloads.phase.downloading": "Downloading",
"downloads.phase.uploading": "Uploading",
@@ -169,6 +183,8 @@
"downloads.detail.tabs.log": "Log",
"downloads.detail.noSelection": "Select a download task to inspect it",
"downloads.detail.target": "Target folder",
"downloads.detail.category": "Category",
"downloads.detail.tags": "Tags",
"downloads.detail.engine": "Engine",
"downloads.detail.phase": "Phase",
"downloads.detail.engineState": "Engine state",
@@ -778,6 +794,7 @@
"common.confirm": "Confirm",
"common.copy": "Copy",
"common.close": "Close",
"common.clear": "Clear",
"common.loading": "Loading...",
"common.error": "Error",
"common.success": "Success",
+17
View File
@@ -125,11 +125,19 @@
"downloads.targetFolderRoot": "根目录",
"downloads.name": "文件名",
"downloads.namePlaceholder": "可选",
"downloads.category": "分类",
"downloads.categoryPlaceholder": "例如:电影、资料、备份",
"downloads.tags": "标签",
"downloads.tagsPlaceholder": "用逗号分隔,例如:4K, 私有",
"downloads.filters": "筛选下载任务",
"downloads.filterCategory": "按分类筛选",
"downloads.filterTag": "按标签筛选",
"downloads.create": "创建",
"downloads.createTitle": "新建远程下载",
"downloads.start": "开始下载",
"downloads.createSuccess": "下载任务已创建",
"downloads.cancelSuccess": "下载任务已取消",
"downloads.actionSuccess": "任务操作已提交",
"downloads.cancel": "取消",
"downloads.resizePanel": "调整下载列表和详情面板高度",
"downloads.empty": "暂无远程下载任务",
@@ -146,11 +154,17 @@
"downloads.status.assigned": "已分配",
"downloads.status.running": "运行中",
"downloads.status.billing_paused": "计费暂停",
"downloads.status.paused": "已暂停",
"downloads.status.uploading": "上传中",
"downloads.status.completed": "已完成",
"downloads.status.seeding": "做种中",
"downloads.status.failed": "失败",
"downloads.status.canceled": "已取消",
"downloads.actions.pause": "暂停",
"downloads.actions.resume": "继续",
"downloads.actions.cancel": "取消",
"downloads.actions.retry": "重试",
"downloads.actions.delete": "删除",
"downloads.phase.metadata": "获取元数据",
"downloads.phase.downloading": "下载中",
"downloads.phase.uploading": "上传中",
@@ -169,6 +183,8 @@
"downloads.detail.tabs.log": "日志",
"downloads.detail.noSelection": "选择一个下载任务查看详情",
"downloads.detail.target": "目标目录",
"downloads.detail.category": "分类",
"downloads.detail.tags": "标签",
"downloads.detail.engine": "下载引擎",
"downloads.detail.phase": "阶段",
"downloads.detail.engineState": "引擎状态",
@@ -778,6 +794,7 @@
"common.confirm": "确认",
"common.copy": "复制",
"common.close": "关闭",
"common.clear": "清除",
"common.loading": "加载中...",
"common.error": "错误",
"common.success": "成功",
+40 -3
View File
@@ -112,6 +112,7 @@ import {
revokeRemoteDownloadApiKey,
revokeSiteInvitation,
revokeWebDavAppPassword,
runDownloadTaskAction,
saveBranding,
saveEmailConfig,
saveShareToDrive,
@@ -1000,13 +1001,22 @@ describe('api', () => {
const payload = { items: [], total: 0, page: 2, pageSize: 10 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await listDownloadTasks({ status: 'running', assignedTo: 'me', page: 2, pageSize: 10 })
const result = await listDownloadTasks({
status: 'running',
assignedTo: 'me',
category: 'movies',
tag: '4k',
page: 2,
pageSize: 10,
})
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/download-tasks?')
expect(url).toContain('status=running')
expect(url).toContain('assignedTo=me')
expect(url).toContain('category=movies')
expect(url).toContain('tag=4k')
expect(url).toContain('page=2')
expect(url).toContain('pageSize=10')
expect(init.method).toBe('GET')
@@ -1014,7 +1024,12 @@ describe('api', () => {
it('creates a download task', async () => {
const payload = { id: 'task-1', status: 'queued' }
const body = { source: { type: 'http' as const, uri: 'https://example.com/file.zip' }, targetFolder: 'root' }
const body = {
source: { type: 'http' as const, uri: 'https://example.com/file.zip' },
targetFolder: 'root',
category: 'archives',
tags: ['backup', '2026'],
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await createDownloadTask(body)
@@ -1040,8 +1055,30 @@ describe('api', () => {
expect(init.body).toBe(JSON.stringify(body))
})
it('runs a download task action', async () => {
const payload = { id: 'task-1', status: 'paused' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await runDownloadTaskAction('task-1', 'pause')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/download-tasks/task-1/actions')
expect(init.method).toBe('POST')
expect(init.body).toBe(JSON.stringify({ action: 'pause' }))
})
it('throws ApiError on download task action failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Only active tasks can be paused' }, false, 409))
await expect(runDownloadTaskAction('task-1', 'pause')).rejects.toThrow('Only active tasks can be paused')
})
it('builds the download task events URL from RPC client', () => {
expect(downloadTaskEventsUrl().pathname).toBe('/api/download-tasks/events')
const url = downloadTaskEventsUrl({ category: 'movies', tag: '4k' })
expect(url.pathname).toBe('/api/download-tasks/events')
expect(url.searchParams.get('category')).toBe('movies')
expect(url.searchParams.get('tag')).toBe('4k')
})
it('lists admin downloaders', async () => {
+16 -2
View File
@@ -15,6 +15,7 @@ import type {
CreateShareRequest,
CreateStorageInput,
DownloaderHeartbeatInput,
DownloadTaskActionInput,
GiftCardStatus,
PatchObjectUploadSessionInput,
PresignObjectUploadPartsInput,
@@ -272,6 +273,8 @@ export function patchObjectUploadSession(id: string, uploadSessionId: string, da
export interface ListDownloadTasksOptions {
status?: string
assignedTo?: 'me'
category?: string
tag?: string
page?: number
pageSize?: number
}
@@ -283,6 +286,8 @@ export function listDownloadTasks(opts: ListDownloadTasksOptions = {}) {
}
if (opts.status) query.status = opts.status
if (opts.assignedTo) query.assignedTo = opts.assignedTo
if (opts.category) query.category = opts.category
if (opts.tag) query.tag = opts.tag
return unwrap<PaginatedResponse<DownloadTask>>(downloadTasksApi.index.$get({ query }))
}
@@ -294,8 +299,17 @@ export function updateDownloadTask(id: string, data: UpdateDownloadTaskInput) {
return unwrap<DownloadTask>(downloadTasksApi[':id'].$patch({ param: { id }, json: data }))
}
export function downloadTaskEventsUrl() {
return downloadTasksUrlApi.events.$url()
export type DownloadTaskActionResult = DownloadTask | { id: string; deleted: true }
export function runDownloadTaskAction(id: string, action: DownloadTaskActionInput['action']) {
return unwrap<DownloadTaskActionResult>(downloadTasksApi[':id'].actions.$post({ param: { id }, json: { action } }))
}
export function downloadTaskEventsUrl(opts: Pick<ListDownloadTasksOptions, 'category' | 'tag'> = {}) {
const query: Record<string, string> = { page: '1', pageSize: '50' }
if (opts.category) query.category = opts.category
if (opts.tag) query.tag = opts.tag
return downloadTasksUrlApi.events.$url({ query })
}
export function listDownloaders() {
+200 -33
View File
@@ -1,5 +1,5 @@
import { DirType } from '@shared/constants'
import type { DownloadTask, DownloadTaskStatus, StorageObject } from '@shared/types'
import type { DownloadTask, DownloadTaskAction, DownloadTaskStatus, StorageObject } from '@shared/types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import {
@@ -11,6 +11,7 @@ import {
Clock,
Download,
FileDown,
Filter,
Folder,
FolderInput,
Gauge,
@@ -19,8 +20,12 @@ import {
LoaderCircle,
Magnet,
PauseCircle,
PlayCircle,
Plus,
RadioTower,
RotateCcw,
Tag,
Trash2,
Upload,
Users,
XCircle,
@@ -31,6 +36,7 @@ import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
@@ -48,7 +54,7 @@ import { Progress } from '@/components/ui/progress'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { createDownloadTask, downloadTaskEventsUrl, listDownloadTasks, updateDownloadTask } from '@/lib/api'
import { createDownloadTask, downloadTaskEventsUrl, listDownloadTasks, runDownloadTaskAction } from '@/lib/api'
import { cn } from '@/lib/utils'
export const Route = createFileRoute('/_authenticated/downloads/')({
@@ -82,26 +88,38 @@ function DownloadsPage() {
const [uri, setUri] = useState('')
const [targetFolder, setTargetFolder] = useState('')
const [name, setName] = useState('')
const [category, setCategory] = useState('')
const [tagsInput, setTagsInput] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [filterTag, setFilterTag] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
const [detailHeight, setDetailHeight] = useState(DETAIL_DEFAULT_HEIGHT)
const [panelDrag, setPanelDrag] = useState<PanelDragState | null>(null)
const panelsRef = useRef<HTMLDivElement>(null)
const categoryFilterValue = filterCategory.trim() || undefined
const tagFilterValue = filterTag.trim() || undefined
const queryKey = useMemo(
() => [...QUERY_KEY, categoryFilterValue ?? '', tagFilterValue ?? ''],
[categoryFilterValue, tagFilterValue],
)
const tasksQuery = useQuery({
queryKey: QUERY_KEY,
queryFn: () => listDownloadTasks({ page: 1, pageSize: 50 }),
queryKey,
queryFn: () => listDownloadTasks({ page: 1, pageSize: 50, category: categoryFilterValue, tag: tagFilterValue }),
})
useEffect(() => {
const events = new EventSource(downloadTaskEventsUrl(), { withCredentials: true })
const events = new EventSource(downloadTaskEventsUrl({ category: categoryFilterValue, tag: tagFilterValue }), {
withCredentials: true,
})
events.addEventListener('snapshot', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data)
queryClient.setQueryData(QUERY_KEY, data)
queryClient.setQueryData(queryKey, data)
})
return () => events.close()
}, [queryClient])
}, [categoryFilterValue, queryClient, queryKey, tagFilterValue])
useEffect(() => {
if (!panelDrag) return
@@ -132,6 +150,8 @@ function DownloadsPage() {
setUri('')
setName('')
setTargetFolder('')
setCategory('')
setTagsInput('')
setCreateOpen(false)
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
toast.success(t('downloads.createSuccess'))
@@ -139,11 +159,11 @@ function DownloadsPage() {
onError: (err) => toast.error(err.message),
})
const pauseMutation = useMutation({
mutationFn: (id: string) => updateDownloadTask(id, { status: 'canceled' }),
const actionMutation = useMutation({
mutationFn: ({ id, action }: { id: string; action: DownloadTaskAction }) => runDownloadTaskAction(id, action),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
toast.success(t('downloads.cancelSuccess'))
toast.success(t('downloads.actionSuccess'))
},
onError: (err) => toast.error(err.message),
})
@@ -154,6 +174,8 @@ function DownloadsPage() {
source: { type: sourceType, uri: uri.trim() },
targetFolder: targetFolder.trim(),
name: name.trim() || undefined,
category: category.trim() || undefined,
tags: parseTagsInput(tagsInput),
})
}
@@ -182,10 +204,18 @@ function DownloadsPage() {
<PageHeader
items={[{ label: t('downloads.title'), icon: <Download className="size-4 text-muted-foreground" /> }]}
actions={
<Button type="button" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
{t('downloads.create')}
</Button>
<div className="flex items-center gap-2">
<DownloadFilters
category={filterCategory}
tag={filterTag}
onCategoryChange={setFilterCategory}
onTagChange={setFilterTag}
/>
<Button type="button" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
{t('downloads.create')}
</Button>
</div>
}
/>
@@ -252,6 +282,30 @@ function DownloadsPage() {
placeholder={t('downloads.namePlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="download-category" className="flex items-center gap-2">
<Tag className="size-4 text-muted-foreground" />
{t('downloads.category')}
</Label>
<Input
id="download-category"
value={category}
onChange={(event) => setCategory(event.target.value)}
placeholder={t('downloads.categoryPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="download-tags" className="flex items-center gap-2">
<Tag className="size-4 text-muted-foreground" />
{t('downloads.tags')}
</Label>
<Input
id="download-tags"
value={tagsInput}
onChange={(event) => setTagsInput(event.target.value)}
placeholder={t('downloads.tagsPlaceholder')}
/>
</div>
</div>
<DialogFooter>
@@ -301,7 +355,8 @@ function DownloadsPage() {
task={task}
selected={task.id === activeSelectedTaskId}
onSelect={() => setSelectedTaskId(task.id)}
onCancel={(id) => pauseMutation.mutate(id)}
actionPending={actionMutation.isPending && actionMutation.variables?.id === task.id}
onAction={(id, action) => actionMutation.mutate({ id, action })}
/>
))}
</TableBody>
@@ -330,6 +385,72 @@ function DownloadsPage() {
)
}
function DownloadFilters({
category,
tag,
onCategoryChange,
onTagChange,
}: {
category: string
tag: string
onCategoryChange: (value: string) => void
onTagChange: (value: string) => void
}) {
const { t } = useTranslation()
const active = Boolean(category || tag)
return (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant={active ? 'secondary' : 'outline'} size="icon" title={t('downloads.filters')}>
<Filter className="size-4" />
<span className="sr-only">{t('downloads.filters')}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="z-[60] w-72 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="download-filter-category" className="text-xs">
{t('downloads.category')}
</Label>
<Input
id="download-filter-category"
value={category}
onChange={(event) => onCategoryChange(event.target.value)}
placeholder={t('downloads.filterCategory')}
className="h-8"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="download-filter-tag" className="text-xs">
{t('downloads.tags')}
</Label>
<Input
id="download-filter-tag"
value={tag}
onChange={(event) => onTagChange(event.target.value)}
placeholder={t('downloads.filterTag')}
className="h-8"
/>
</div>
{active && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => {
onCategoryChange('')
onTagChange('')
}}
>
{t('common.clear')}
</Button>
)}
</PopoverContent>
</Popover>
)
}
function buildPath(parent: string, name: string): string {
return parent ? `${parent}/${name}` : name
}
@@ -338,6 +459,17 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function parseTagsInput(value: string): string[] {
return [
...new Set(
value
.split(',')
.map((item) => item.trim())
.filter(Boolean),
),
]
}
function FolderPicker({ value, onChange }: { value: string; onChange: (path: string) => void }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -450,16 +582,18 @@ function TaskRow({
task,
selected,
onSelect,
onCancel,
actionPending,
onAction,
}: {
task: DownloadTask
selected: boolean
onSelect: () => void
onCancel: (id: string) => void
actionPending: boolean
onAction: (id: string, action: DownloadTaskAction) => void
}) {
const { t } = useTranslation()
const progress = transferProgress(task)
const active = ACTIVE_STATUSES.has(task.status)
const actions = taskActions(task)
return (
<TableRow
@@ -494,28 +628,54 @@ function TaskRow({
<TableCell className="whitespace-nowrap py-1 text-[11px] tabular-nums text-muted-foreground">
{formatDuration(task.detail?.etaSeconds)}
</TableCell>
<TableCell className="py-1 text-right">
{active ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
onClick={(event) => {
event.stopPropagation()
onCancel(task.id)
}}
>
<XCircle className="size-4" />
{t('downloads.cancel')}
</Button>
<TableCell className="py-1">
{actions.length > 0 ? (
<div className="flex justify-end gap-1">
{actions.map((action) => (
<Button
key={action}
variant="ghost"
size="icon-xs"
className={cn(
'text-muted-foreground',
(action === 'cancel' || action === 'delete') && 'hover:text-destructive',
)}
title={t(`downloads.actions.${action}`)}
disabled={actionPending}
onClick={(event) => {
event.stopPropagation()
onAction(task.id, action)
}}
>
<TaskActionIcon action={action} />
<span className="sr-only">{t(`downloads.actions.${action}`)}</span>
</Button>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
<div className="text-right text-xs text-muted-foreground">-</div>
)}
</TableCell>
</TableRow>
)
}
function taskActions(task: DownloadTask): DownloadTaskAction[] {
if (ACTIVE_STATUSES.has(task.status)) return ['pause', 'cancel']
if (task.status === 'paused') return ['resume', 'cancel']
if (task.status === 'failed' || task.status === 'canceled') return ['retry', 'delete']
if (task.status === 'completed') return ['delete']
return []
}
function TaskActionIcon({ action }: { action: DownloadTaskAction }) {
if (action === 'pause') return <PauseCircle />
if (action === 'resume') return <PlayCircle />
if (action === 'retry') return <RotateCcw />
if (action === 'delete') return <Trash2 />
return <XCircle />
}
function TransferProgress({ task, className }: { task: DownloadTask; className?: string }) {
const progress = transferProgress(task)
return (
@@ -649,6 +809,8 @@ function OverviewPanel({ task }: { task: DownloadTask }) {
label={t('downloads.detail.target')}
value={task.targetFolder || t('downloads.targetFolderRoot')}
/>
<InspectorField label={t('downloads.detail.category')} value={task.category || '-'} />
<InspectorField label={t('downloads.detail.tags')} value={task.tags.length ? task.tags.join(', ') : '-'} />
<InspectorField
label={t('downloads.detail.sourceType')}
value={t(`downloads.sourceTypes.${sourceTypeKey(task)}`)}
@@ -926,6 +1088,11 @@ function StatusBadge({ status }: { status: DownloadTaskDisplayStatus }) {
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
icon: <PauseCircle />,
},
paused: {
className:
'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300',
icon: <PauseCircle />,
},
uploading: {
className: 'border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-800 dark:bg-teal-950/40 dark:text-teal-300',
icon: <Upload />,