mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat(downloads): add task actions and classification
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user