|
|
@@ -0,0 +1,809 @@
|
|
|
+import Logger from './Logger';
|
|
|
+import { FileUtil, PreferencesUtil, StrUtil } from '@pura/harmony-utils';
|
|
|
+import { fileIo, fileUri } from '@kit.CoreFileKit';
|
|
|
+import { http } from '@kit.NetworkKit';
|
|
|
+import { rcp } from '@kit.RemoteCommunicationKit';
|
|
|
+
|
|
|
+const TAG: string = 'DownloadCenterManager';
|
|
|
+const HISTORY_KEY: string = 'download_center_completed_history_v1';
|
|
|
+const MAX_HISTORY_COUNT: number = 300;
|
|
|
+const MAX_CONCURRENT_DOWNLOADS: number = 3;
|
|
|
+
|
|
|
+export type DownloadCenterTaskStatus = 'pending' | 'downloading' | 'paused' | 'completed' | 'failed';
|
|
|
+
|
|
|
+export interface DownloadCenterTask {
|
|
|
+ taskId: string;
|
|
|
+ title: string;
|
|
|
+ fileName: string;
|
|
|
+ coverPath: string;
|
|
|
+ sizeText: string;
|
|
|
+ sourceUrl: string;
|
|
|
+ targetPath: string;
|
|
|
+ downloadDir: string;
|
|
|
+ totalBytes: number;
|
|
|
+ downloadedBytes: number;
|
|
|
+ progress: number;
|
|
|
+ speedBytesPerSec: number;
|
|
|
+ status: DownloadCenterTaskStatus;
|
|
|
+ errorMessage: string;
|
|
|
+ createdAt: number;
|
|
|
+ finishedAt: number;
|
|
|
+}
|
|
|
+
|
|
|
+export interface DownloadEnqueueOptions {
|
|
|
+ title: string;
|
|
|
+ fileName: string;
|
|
|
+ coverPath?: string;
|
|
|
+ sizeText?: string;
|
|
|
+ sourceUrl: string;
|
|
|
+ targetPath: string;
|
|
|
+ downloadDir: string;
|
|
|
+ headers?: Map<string, string>;
|
|
|
+ expectedBytes?: number;
|
|
|
+ onCompleted?: (task: DownloadCenterTask) => Promise<void> | void;
|
|
|
+}
|
|
|
+
|
|
|
+interface DownloadRuntimeContext {
|
|
|
+ task: DownloadCenterTask;
|
|
|
+ headers: Map<string, string>;
|
|
|
+ onCompleted?: (task: DownloadCenterTask) => Promise<void> | void;
|
|
|
+ pauseRequested: boolean;
|
|
|
+ isQueued: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+class DownloadPausedError extends Error {
|
|
|
+ constructor() {
|
|
|
+ super('download_paused');
|
|
|
+ this.name = 'DownloadPausedError';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class DownloadCenterManager {
|
|
|
+ private static instance: DownloadCenterManager;
|
|
|
+ private observers: Array<() => void> = [];
|
|
|
+ private queue: DownloadRuntimeContext[] = [];
|
|
|
+ private runtimeMap: Map<string, DownloadRuntimeContext> = new Map<string, DownloadRuntimeContext>();
|
|
|
+ private activeTasks: DownloadCenterTask[] = [];
|
|
|
+ private completedTasks: DownloadCenterTask[] = [];
|
|
|
+ private isProcessing: boolean = false;
|
|
|
+ private runningTaskIds: Set<string> = new Set<string>();
|
|
|
+
|
|
|
+ private constructor() {
|
|
|
+ this.loadCompletedHistory();
|
|
|
+ }
|
|
|
+
|
|
|
+ public static getInstance(): DownloadCenterManager {
|
|
|
+ if (!DownloadCenterManager.instance) {
|
|
|
+ DownloadCenterManager.instance = new DownloadCenterManager();
|
|
|
+ }
|
|
|
+ return DownloadCenterManager.instance;
|
|
|
+ }
|
|
|
+
|
|
|
+ public subscribe(callback: () => void): void {
|
|
|
+ this.observers.push(callback);
|
|
|
+ }
|
|
|
+
|
|
|
+ public unsubscribe(callback: () => void): void {
|
|
|
+ const index: number = this.observers.indexOf(callback);
|
|
|
+ if (index >= 0) {
|
|
|
+ this.observers.splice(index, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public getActiveTasks(): DownloadCenterTask[] {
|
|
|
+ const sorted = this.activeTasks.slice().sort((a: DownloadCenterTask, b: DownloadCenterTask): number => {
|
|
|
+ if (b.createdAt !== a.createdAt) {
|
|
|
+ return b.createdAt - a.createdAt;
|
|
|
+ }
|
|
|
+ if (a.taskId > b.taskId) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (a.taskId < b.taskId) {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ });
|
|
|
+ return sorted.map((task: DownloadCenterTask): DownloadCenterTask => this.cloneTask(task));
|
|
|
+ }
|
|
|
+
|
|
|
+ public getCompletedTasks(): DownloadCenterTask[] {
|
|
|
+ const sorted = this.completedTasks.slice().sort((a: DownloadCenterTask, b: DownloadCenterTask): number => {
|
|
|
+ if (b.finishedAt !== a.finishedAt) {
|
|
|
+ return b.finishedAt - a.finishedAt;
|
|
|
+ }
|
|
|
+ if (a.taskId > b.taskId) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (a.taskId < b.taskId) {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ });
|
|
|
+ return sorted.map((task: DownloadCenterTask): DownloadCenterTask => this.cloneTask(task));
|
|
|
+ }
|
|
|
+
|
|
|
+ public enqueueDownload(options: DownloadEnqueueOptions): DownloadCenterTask {
|
|
|
+ const now: number = Date.now();
|
|
|
+ const title: string = StrUtil.isNotEmpty(options.title) ? options.title : options.fileName;
|
|
|
+ const fileName: string = StrUtil.isNotEmpty(options.fileName) ? options.fileName : '未知文件';
|
|
|
+ const expectedBytes: number = options.expectedBytes && options.expectedBytes > 0
|
|
|
+ ? options.expectedBytes
|
|
|
+ : this.parseSizeTextToBytes(options.sizeText ?? '');
|
|
|
+
|
|
|
+ const task: DownloadCenterTask = {
|
|
|
+ taskId: `${now}_${Math.floor(Math.random() * 1000000)}`,
|
|
|
+ title: title,
|
|
|
+ fileName: fileName,
|
|
|
+ coverPath: options.coverPath ?? '',
|
|
|
+ sizeText: options.sizeText ?? '',
|
|
|
+ sourceUrl: options.sourceUrl,
|
|
|
+ targetPath: options.targetPath,
|
|
|
+ downloadDir: options.downloadDir,
|
|
|
+ totalBytes: expectedBytes,
|
|
|
+ downloadedBytes: 0,
|
|
|
+ progress: 0,
|
|
|
+ speedBytesPerSec: 0,
|
|
|
+ status: 'pending',
|
|
|
+ errorMessage: '',
|
|
|
+ createdAt: now,
|
|
|
+ finishedAt: 0
|
|
|
+ };
|
|
|
+
|
|
|
+ const runtime: DownloadRuntimeContext = {
|
|
|
+ task: task,
|
|
|
+ headers: options.headers ? new Map(options.headers) : new Map<string, string>(),
|
|
|
+ onCompleted: options.onCompleted,
|
|
|
+ pauseRequested: false,
|
|
|
+ isQueued: true
|
|
|
+ };
|
|
|
+
|
|
|
+ this.activeTasks.unshift(task);
|
|
|
+ this.runtimeMap.set(task.taskId, runtime);
|
|
|
+ this.queue.push(runtime);
|
|
|
+ this.notifyObservers();
|
|
|
+ void this.processQueue();
|
|
|
+
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+
|
|
|
+ public pauseTask(taskId: string): void {
|
|
|
+ const runtime = this.runtimeMap.get(taskId);
|
|
|
+ const active = this.findActiveTask(taskId);
|
|
|
+ if (!runtime || !active || active.status === 'completed') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ runtime.pauseRequested = true;
|
|
|
+ active.speedBytesPerSec = 0;
|
|
|
+ active.errorMessage = '';
|
|
|
+
|
|
|
+ if (active.status === 'pending') {
|
|
|
+ active.status = 'paused';
|
|
|
+ this.removeRuntimeFromQueue(taskId);
|
|
|
+ runtime.isQueued = false;
|
|
|
+ this.notifyObservers();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (active.status === 'downloading') {
|
|
|
+ active.status = 'paused';
|
|
|
+ this.notifyObservers();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public resumeTask(taskId: string): void {
|
|
|
+ const runtime = this.runtimeMap.get(taskId);
|
|
|
+ const active = this.findActiveTask(taskId);
|
|
|
+ if (!runtime || !active || active.status === 'completed') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ runtime.pauseRequested = false;
|
|
|
+ active.errorMessage = '';
|
|
|
+ active.speedBytesPerSec = 0;
|
|
|
+
|
|
|
+ if (!runtime.isQueued) {
|
|
|
+ this.queue.push(runtime);
|
|
|
+ runtime.isQueued = true;
|
|
|
+ }
|
|
|
+ if (active.status !== 'downloading') {
|
|
|
+ active.status = 'pending';
|
|
|
+ }
|
|
|
+ this.notifyObservers();
|
|
|
+ void this.processQueue();
|
|
|
+ }
|
|
|
+
|
|
|
+ private removeRuntimeFromQueue(taskId: string): void {
|
|
|
+ const queueIndex = this.queue.findIndex((runtime: DownloadRuntimeContext): boolean => {
|
|
|
+ return runtime.task.taskId === taskId;
|
|
|
+ });
|
|
|
+ if (queueIndex >= 0) {
|
|
|
+ this.queue.splice(queueIndex, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private processQueue(): void {
|
|
|
+ if (this.isProcessing) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.isProcessing = true;
|
|
|
+ try {
|
|
|
+ while (this.runningTaskIds.size < MAX_CONCURRENT_DOWNLOADS && this.queue.length > 0) {
|
|
|
+ const runtime = this.queue.shift();
|
|
|
+ if (!runtime) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ runtime.isQueued = false;
|
|
|
+
|
|
|
+ const active = this.findActiveTask(runtime.task.taskId);
|
|
|
+ if (!active) {
|
|
|
+ this.runtimeMap.delete(runtime.task.taskId);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (this.runningTaskIds.has(active.taskId)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (runtime.pauseRequested || active.status === 'paused') {
|
|
|
+ active.status = 'paused';
|
|
|
+ active.speedBytesPerSec = 0;
|
|
|
+ this.notifyObservers();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.runningTaskIds.add(active.taskId);
|
|
|
+ active.status = 'downloading';
|
|
|
+ active.errorMessage = '';
|
|
|
+ active.finishedAt = 0;
|
|
|
+ this.notifyObservers();
|
|
|
+ void this.executeRuntime(runtime, active);
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ this.isProcessing = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeRuntime(runtime: DownloadRuntimeContext, active: DownloadCenterTask): Promise<void> {
|
|
|
+ try {
|
|
|
+ await this.executeDownload(runtime);
|
|
|
+ if (runtime.pauseRequested) {
|
|
|
+ active.status = 'paused';
|
|
|
+ active.speedBytesPerSec = 0;
|
|
|
+ this.notifyObservers();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ active.status = 'completed';
|
|
|
+ active.progress = 100;
|
|
|
+ active.speedBytesPerSec = 0;
|
|
|
+ active.finishedAt = Date.now();
|
|
|
+
|
|
|
+ if (active.totalBytes <= 0 && FileUtil.accessSync(active.targetPath)) {
|
|
|
+ const stat = fileIo.statSync(active.targetPath);
|
|
|
+ active.totalBytes = stat.size;
|
|
|
+ active.downloadedBytes = stat.size;
|
|
|
+ }
|
|
|
+ if (StrUtil.isEmpty(active.sizeText) && active.totalBytes > 0) {
|
|
|
+ active.sizeText = this.formatBytes(active.totalBytes);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.activeTasks = this.activeTasks.filter((item: DownloadCenterTask): boolean => {
|
|
|
+ return item.taskId !== active.taskId;
|
|
|
+ });
|
|
|
+ this.runtimeMap.delete(active.taskId);
|
|
|
+ this.completedTasks.unshift(this.cloneTask(active));
|
|
|
+ if (this.completedTasks.length > MAX_HISTORY_COUNT) {
|
|
|
+ this.completedTasks = this.completedTasks.slice(0, MAX_HISTORY_COUNT);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (runtime.onCompleted) {
|
|
|
+ try {
|
|
|
+ await runtime.onCompleted(this.cloneTask(active));
|
|
|
+ } catch (callbackError) {
|
|
|
+ Logger.warn(TAG, `下载后置回调失败: ${(callbackError as Error).message}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.saveCompletedHistory();
|
|
|
+ this.notifyObservers();
|
|
|
+ } catch (error) {
|
|
|
+ if (this.isPauseError(error)) {
|
|
|
+ active.status = 'paused';
|
|
|
+ active.errorMessage = '';
|
|
|
+ active.speedBytesPerSec = 0;
|
|
|
+ this.notifyObservers();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const err = error as Error;
|
|
|
+ active.status = 'failed';
|
|
|
+ active.speedBytesPerSec = 0;
|
|
|
+ active.errorMessage = err.message;
|
|
|
+ this.notifyObservers();
|
|
|
+ Logger.error(TAG, `下载失败: ${active.fileName}, err=${err.message}`);
|
|
|
+ } finally {
|
|
|
+ this.runningTaskIds.delete(active.taskId);
|
|
|
+ this.processQueue();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private findActiveTask(taskId: string): DownloadCenterTask | undefined {
|
|
|
+ return this.activeTasks.find((task: DownloadCenterTask): boolean => {
|
|
|
+ return task.taskId === taskId;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private isPauseError(error: Error | Object): boolean {
|
|
|
+ return error instanceof DownloadPausedError;
|
|
|
+ }
|
|
|
+
|
|
|
+ private assertNotPaused(runtime: DownloadRuntimeContext): void {
|
|
|
+ if (runtime.pauseRequested || runtime.task.status === 'paused') {
|
|
|
+ throw new DownloadPausedError();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeDownload(runtime: DownloadRuntimeContext): Promise<void> {
|
|
|
+ const task: DownloadCenterTask = runtime.task;
|
|
|
+ const sourceUrl: string = task.sourceUrl;
|
|
|
+ const isFileUri: boolean = sourceUrl.startsWith('file://');
|
|
|
+ const isLocalPath: boolean = sourceUrl.startsWith('/');
|
|
|
+
|
|
|
+ if (isFileUri || isLocalPath) {
|
|
|
+ const localPath: string = isFileUri ? new fileUri.FileUri(sourceUrl).path : sourceUrl;
|
|
|
+ await this.copyLocalFileWithProgress(runtime, localPath);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.downloadRemoteFileWithResume(runtime);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async copyLocalFileWithProgress(runtime: DownloadRuntimeContext, sourcePath: string): Promise<void> {
|
|
|
+ const task = runtime.task;
|
|
|
+ if (!FileUtil.accessSync(sourcePath)) {
|
|
|
+ throw new Error('本地源文件不存在');
|
|
|
+ }
|
|
|
+
|
|
|
+ const sourceStat: fileIo.Stat = fileIo.statSync(sourcePath);
|
|
|
+ task.totalBytes = sourceStat.size;
|
|
|
+ task.downloadedBytes = 0;
|
|
|
+ task.progress = 0;
|
|
|
+ task.speedBytesPerSec = 0;
|
|
|
+ if (task.totalBytes > 0 && StrUtil.isEmpty(task.sizeText)) {
|
|
|
+ task.sizeText = this.formatBytes(task.totalBytes);
|
|
|
+ }
|
|
|
+ this.notifyObservers();
|
|
|
+
|
|
|
+ const sourceFile: fileIo.File = fileIo.openSync(sourcePath, fileIo.OpenMode.READ_ONLY);
|
|
|
+ const targetFile: fileIo.File = fileIo.openSync(task.targetPath,
|
|
|
+ fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
|
|
+
|
|
|
+ const chunkSize: number = 256 * 1024;
|
|
|
+ const chunkBuffer: ArrayBuffer = new ArrayBuffer(chunkSize);
|
|
|
+ let totalRead: number = 0;
|
|
|
+ let speedSampleBytes: number = 0;
|
|
|
+ let speedSampleStartAt: number = Date.now();
|
|
|
+
|
|
|
+ try {
|
|
|
+ while (true) {
|
|
|
+ this.assertNotPaused(runtime);
|
|
|
+ const readLen: number = fileIo.readSync(sourceFile.fd, chunkBuffer);
|
|
|
+ if (readLen <= 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (readLen === chunkBuffer.byteLength) {
|
|
|
+ fileIo.writeSync(targetFile.fd, chunkBuffer);
|
|
|
+ } else {
|
|
|
+ fileIo.writeSync(targetFile.fd, chunkBuffer, { length: readLen });
|
|
|
+ }
|
|
|
+
|
|
|
+ totalRead += readLen;
|
|
|
+ speedSampleBytes += readLen;
|
|
|
+ this.updateTaskSpeed(task, speedSampleStartAt, speedSampleBytes);
|
|
|
+ this.updateTaskProgress(task, totalRead, task.totalBytes);
|
|
|
+
|
|
|
+ const now = Date.now();
|
|
|
+ if (now - speedSampleStartAt >= 500) {
|
|
|
+ speedSampleStartAt = now;
|
|
|
+ speedSampleBytes = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ fileIo.closeSync(sourceFile);
|
|
|
+ fileIo.closeSync(targetFile);
|
|
|
+ }
|
|
|
+
|
|
|
+ task.downloadedBytes = totalRead;
|
|
|
+ task.progress = 100;
|
|
|
+ task.speedBytesPerSec = 0;
|
|
|
+ this.notifyObservers();
|
|
|
+ }
|
|
|
+
|
|
|
+ private async downloadRemoteFileWithResume(runtime: DownloadRuntimeContext): Promise<void> {
|
|
|
+ const task = runtime.task;
|
|
|
+ const expectedTotal = await this.resolveRemoteFileTotalBytes(task.sourceUrl, runtime.headers, task.totalBytes);
|
|
|
+ if (expectedTotal > 0) {
|
|
|
+ task.totalBytes = expectedTotal;
|
|
|
+ if (StrUtil.isEmpty(task.sizeText)) {
|
|
|
+ task.sizeText = this.formatBytes(expectedTotal);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let downloadedBytes: number = 0;
|
|
|
+ if (FileUtil.accessSync(task.targetPath)) {
|
|
|
+ const stat = fileIo.statSync(task.targetPath);
|
|
|
+ if (stat.size > 0) {
|
|
|
+ downloadedBytes = stat.size;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (task.totalBytes > 0 && downloadedBytes > task.totalBytes) {
|
|
|
+ downloadedBytes = 0;
|
|
|
+ }
|
|
|
+ if (downloadedBytes > 0) {
|
|
|
+ this.updateTaskProgress(task, downloadedBytes, task.totalBytes);
|
|
|
+ this.notifyObservers();
|
|
|
+ }
|
|
|
+
|
|
|
+ let fileMode: number = fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE;
|
|
|
+ if (downloadedBytes <= 0) {
|
|
|
+ fileMode = fileMode | fileIo.OpenMode.TRUNC;
|
|
|
+ }
|
|
|
+ let fileHandle = await fileIo.open(task.targetPath, fileMode);
|
|
|
+
|
|
|
+ let speedSampleBytes: number = 0;
|
|
|
+ let speedSampleStartAt: number = Date.now();
|
|
|
+ let nextLogBytesMark: number = 0;
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (task.totalBytes > 0 && downloadedBytes >= task.totalBytes) {
|
|
|
+ task.downloadedBytes = downloadedBytes;
|
|
|
+ task.progress = 100;
|
|
|
+ task.speedBytesPerSec = 0;
|
|
|
+ this.notifyObservers();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ this.assertNotPaused(runtime);
|
|
|
+ if (task.totalBytes > 0 && downloadedBytes >= task.totalBytes) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ const rangeStart: number = downloadedBytes;
|
|
|
+ const sessionHeaders: rcp.RequestHeaders = {
|
|
|
+ 'User-Agent': 'TTMusic-DownloadCenter/1.0',
|
|
|
+ 'Accept': '*/*',
|
|
|
+ 'Method': 'GET'
|
|
|
+ };
|
|
|
+ runtime.headers.forEach((value: string, key: string): void => {
|
|
|
+ if (StrUtil.isNotEmpty(key) && StrUtil.isNotEmpty(value)) {
|
|
|
+ sessionHeaders[key] = value;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (rangeStart > 0) {
|
|
|
+ sessionHeaders['Range'] = `bytes=${rangeStart}-`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const session = rcp.createSession({ headers: sessionHeaders });
|
|
|
+ const owner = this;
|
|
|
+ const streamData: rcp.WriteStream = {
|
|
|
+ async write(buffer: ArrayBuffer): Promise<number> {
|
|
|
+ owner.assertNotPaused(runtime);
|
|
|
+ if (!buffer || buffer.byteLength <= 0) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ await fileIo.write(fileHandle.fd, buffer, {
|
|
|
+ offset: downloadedBytes,
|
|
|
+ length: buffer.byteLength
|
|
|
+ });
|
|
|
+ downloadedBytes += buffer.byteLength;
|
|
|
+ speedSampleBytes += buffer.byteLength;
|
|
|
+
|
|
|
+ owner.updateTaskSpeed(task, speedSampleStartAt, speedSampleBytes);
|
|
|
+ owner.updateTaskProgress(task, downloadedBytes, task.totalBytes);
|
|
|
+
|
|
|
+ if (downloadedBytes >= nextLogBytesMark) {
|
|
|
+ Logger.info(TAG, `下载进度: task=${task.fileName}, chunk=${buffer.byteLength}, downloaded=${downloadedBytes}, total=${task.totalBytes}`);
|
|
|
+ nextLogBytesMark = downloadedBytes + 5 * 1024 * 1024;
|
|
|
+ }
|
|
|
+
|
|
|
+ const now = Date.now();
|
|
|
+ if (now - speedSampleStartAt >= 500) {
|
|
|
+ speedSampleStartAt = now;
|
|
|
+ speedSampleBytes = 0;
|
|
|
+ }
|
|
|
+ return buffer.byteLength;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const downloadToStream: rcp.DownloadToStream = {
|
|
|
+ kind: 'stream',
|
|
|
+ stream: streamData
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ await session.downloadToStream(task.sourceUrl, downloadToStream);
|
|
|
+ } catch (error) {
|
|
|
+ if (this.isPauseError(error)) {
|
|
|
+ throw new DownloadPausedError();
|
|
|
+ }
|
|
|
+ const err = error as Error;
|
|
|
+ if (downloadedBytes > rangeStart) {
|
|
|
+ Logger.warn(TAG, `下载中断,准备断点续传: task=${task.fileName}, downloaded=${downloadedBytes}, err=${err.message}`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ throw err;
|
|
|
+ } finally {
|
|
|
+ session.close();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (task.totalBytes > 0) {
|
|
|
+ if (downloadedBytes >= task.totalBytes) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ // 服务端提前断开,继续按断点续传拉取剩余字节
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // 未知总大小时,单次流拉取结束即认为下载完成
|
|
|
+ task.totalBytes = downloadedBytes;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ this.safeCloseFile(fileHandle);
|
|
|
+ }
|
|
|
+
|
|
|
+ task.downloadedBytes = downloadedBytes;
|
|
|
+ if (task.totalBytes <= 0) {
|
|
|
+ task.totalBytes = downloadedBytes;
|
|
|
+ }
|
|
|
+ task.progress = 100;
|
|
|
+ task.speedBytesPerSec = 0;
|
|
|
+ this.notifyObservers();
|
|
|
+ }
|
|
|
+
|
|
|
+ private updateTaskProgress(task: DownloadCenterTask, downloadedBytes: number, totalBytes: number): void {
|
|
|
+ task.downloadedBytes = downloadedBytes;
|
|
|
+ if (totalBytes > 0) {
|
|
|
+ task.totalBytes = totalBytes;
|
|
|
+ const percent = (downloadedBytes / totalBytes) * 100;
|
|
|
+ const minVisible = downloadedBytes > 0 ? 0.1 : 0;
|
|
|
+ const normalized = Math.max(minVisible, percent);
|
|
|
+ task.progress = Math.min(99.9, Math.max(0, Math.round(normalized * 10) / 10));
|
|
|
+ } else {
|
|
|
+ task.progress = this.estimateProgressWhenTotalUnknown(downloadedBytes);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 立即通知观察者,确保实时更新
|
|
|
+ this.notifyObservers();
|
|
|
+ }
|
|
|
+
|
|
|
+ private updateTaskSpeed(task: DownloadCenterTask, sampleStartAt: number, sampleBytes: number): void {
|
|
|
+ if (sampleBytes <= 0) {
|
|
|
+ task.speedBytesPerSec = 0;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const duration = Math.max(1, Date.now() - sampleStartAt);
|
|
|
+ task.speedBytesPerSec = Math.max(0, Math.floor((sampleBytes * 1000) / duration));
|
|
|
+ }
|
|
|
+
|
|
|
+ private estimateProgressWhenTotalUnknown(downloadedBytes: number): number {
|
|
|
+ if (downloadedBytes <= 0) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ const perStepBytes: number = 256 * 1024;
|
|
|
+ const estimated = Math.ceil(downloadedBytes / perStepBytes);
|
|
|
+ return Math.min(95, Math.max(1, estimated));
|
|
|
+ }
|
|
|
+
|
|
|
+ private async resolveRemoteFileTotalBytes(url: string, headers: Map<string, string>, expectedBytes: number): Promise<number> {
|
|
|
+ if (expectedBytes > 0) {
|
|
|
+ return expectedBytes;
|
|
|
+ }
|
|
|
+ return this.tryFetchContentLength(url, headers);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async tryFetchContentLength(url: string, headers: Map<string, string>): Promise<number> {
|
|
|
+ const httpRequest = http.createHttp();
|
|
|
+ try {
|
|
|
+ const headerMap: Record<string, string> = {};
|
|
|
+ headers.forEach((value: string, key: string): void => {
|
|
|
+ if (StrUtil.isNotEmpty(key) && StrUtil.isNotEmpty(value)) {
|
|
|
+ headerMap[key] = value;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ const response = await httpRequest.request(url, {
|
|
|
+ method: http.RequestMethod.HEAD,
|
|
|
+ connectTimeout: 15000,
|
|
|
+ readTimeout: 15000,
|
|
|
+ expectDataType: http.HttpDataType.STRING,
|
|
|
+ header: headerMap
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.responseCode < 200 || response.responseCode >= 400) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ return this.resolveContentLengthFromHeader(response.header);
|
|
|
+ } catch (_error) {
|
|
|
+ return 0;
|
|
|
+ } finally {
|
|
|
+ httpRequest.destroy();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private resolveContentLengthFromHeader(header: Object | undefined): number {
|
|
|
+ const rawLength = this.readHeaderValue(header, 'Content-Length');
|
|
|
+ if (StrUtil.isEmpty(rawLength)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ const parsed = parseInt(rawLength, 10);
|
|
|
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private readHeaderValue(header: Object | undefined, key: string): string {
|
|
|
+ if (!header || StrUtil.isEmpty(key)) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ const source = header as Record<string, Object>;
|
|
|
+ const keys = Object.keys(source);
|
|
|
+ for (let i = 0; i < keys.length; i += 1) {
|
|
|
+ const itemKey = keys[i];
|
|
|
+ if (itemKey.toLowerCase() !== key.toLowerCase()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const rawValue = source[itemKey];
|
|
|
+ if (typeof rawValue === 'string') {
|
|
|
+ return rawValue;
|
|
|
+ }
|
|
|
+ if (typeof rawValue === 'number') {
|
|
|
+ return `${rawValue}`;
|
|
|
+ }
|
|
|
+ if (rawValue !== null && rawValue !== undefined) {
|
|
|
+ return `${rawValue}`;
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
+ private safeCloseFile(file: fileIo.File | null): void {
|
|
|
+ if (!file) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ fileIo.closeSync(file);
|
|
|
+ } catch (_error) {
|
|
|
+ // ignored
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private parseSizeTextToBytes(sizeText: string): number {
|
|
|
+ if (StrUtil.isEmpty(sizeText)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ const match = sizeText.trim().match(/([0-9]+(?:\.[0-9]+)?)\s*(B|KB|MB|GB|TB)/i);
|
|
|
+ if (!match) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ const value = parseFloat(match[1]);
|
|
|
+ if (!Number.isFinite(value) || value <= 0) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ const unit = match[2].toUpperCase();
|
|
|
+ const factor = this.resolveUnitFactor(unit);
|
|
|
+ return Math.floor(value * factor);
|
|
|
+ }
|
|
|
+
|
|
|
+ private resolveUnitFactor(unit: string): number {
|
|
|
+ switch (unit) {
|
|
|
+ case 'KB':
|
|
|
+ return 1024;
|
|
|
+ case 'MB':
|
|
|
+ return 1024 * 1024;
|
|
|
+ case 'GB':
|
|
|
+ return 1024 * 1024 * 1024;
|
|
|
+ case 'TB':
|
|
|
+ return 1024 * 1024 * 1024 * 1024;
|
|
|
+ case 'B':
|
|
|
+ default:
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private cloneTask(task: DownloadCenterTask): DownloadCenterTask {
|
|
|
+ const copied: DownloadCenterTask = {
|
|
|
+ taskId: task.taskId,
|
|
|
+ title: task.title,
|
|
|
+ fileName: task.fileName,
|
|
|
+ coverPath: task.coverPath,
|
|
|
+ sizeText: task.sizeText,
|
|
|
+ sourceUrl: task.sourceUrl,
|
|
|
+ targetPath: task.targetPath,
|
|
|
+ downloadDir: task.downloadDir,
|
|
|
+ totalBytes: task.totalBytes,
|
|
|
+ downloadedBytes: task.downloadedBytes,
|
|
|
+ progress: task.progress,
|
|
|
+ speedBytesPerSec: task.speedBytesPerSec,
|
|
|
+ status: task.status,
|
|
|
+ errorMessage: task.errorMessage,
|
|
|
+ createdAt: task.createdAt,
|
|
|
+ finishedAt: task.finishedAt
|
|
|
+ };
|
|
|
+ return copied;
|
|
|
+ }
|
|
|
+
|
|
|
+ private formatBytes(bytes: number): string {
|
|
|
+ if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ const units: string[] = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
+ let size: number = bytes;
|
|
|
+ let index: number = 0;
|
|
|
+ while (size >= 1024 && index < units.length - 1) {
|
|
|
+ size /= 1024;
|
|
|
+ index += 1;
|
|
|
+ }
|
|
|
+ const precision: number = index === 0 ? 0 : 2;
|
|
|
+ return `${size.toFixed(precision)} ${units[index]}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadCompletedHistory(): void {
|
|
|
+ try {
|
|
|
+ const historyJson: string = PreferencesUtil.getStringSync(HISTORY_KEY, '');
|
|
|
+ if (StrUtil.isEmpty(historyJson)) {
|
|
|
+ this.completedTasks = [];
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const parsed = JSON.parse(historyJson) as DownloadCenterTask[];
|
|
|
+ if (!parsed || !Array.isArray(parsed)) {
|
|
|
+ this.completedTasks = [];
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.completedTasks = parsed.map((item: DownloadCenterTask): DownloadCenterTask => {
|
|
|
+ return {
|
|
|
+ taskId: item.taskId,
|
|
|
+ title: item.title,
|
|
|
+ fileName: item.fileName,
|
|
|
+ coverPath: item.coverPath,
|
|
|
+ sizeText: item.sizeText,
|
|
|
+ sourceUrl: item.sourceUrl,
|
|
|
+ targetPath: item.targetPath,
|
|
|
+ downloadDir: item.downloadDir,
|
|
|
+ totalBytes: item.totalBytes,
|
|
|
+ downloadedBytes: item.downloadedBytes,
|
|
|
+ progress: item.progress,
|
|
|
+ speedBytesPerSec: 0,
|
|
|
+ status: 'completed',
|
|
|
+ errorMessage: '',
|
|
|
+ createdAt: item.createdAt,
|
|
|
+ finishedAt: item.finishedAt
|
|
|
+ };
|
|
|
+ }).filter((item: DownloadCenterTask): boolean => {
|
|
|
+ return StrUtil.isNotEmpty(item.targetPath) && StrUtil.isNotEmpty(item.fileName);
|
|
|
+ }).slice(0, MAX_HISTORY_COUNT);
|
|
|
+ } catch (error) {
|
|
|
+ this.completedTasks = [];
|
|
|
+ Logger.warn(TAG, `加载下载历史失败: ${(error as Error).message}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private saveCompletedHistory(): void {
|
|
|
+ try {
|
|
|
+ const serialized = JSON.stringify(this.completedTasks);
|
|
|
+ PreferencesUtil.putSync(HISTORY_KEY, serialized);
|
|
|
+ } catch (error) {
|
|
|
+ Logger.warn(TAG, `保存下载历史失败: ${(error as Error).message}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private notifyObservers(): void {
|
|
|
+ for (let i: number = 0; i < this.observers.length; i += 1) {
|
|
|
+ try {
|
|
|
+ this.observers[i]();
|
|
|
+ } catch (error) {
|
|
|
+ Logger.warn(TAG, `通知下载观察者失败: ${(error as Error).message}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|