Răsfoiți Sursa

提交下载中心工具类

onecold 5 luni în urmă
părinte
comite
4972d12859

+ 809 - 0
entry/src/main/ets/common/util/DownloadCenterManager.ets

@@ -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}`);
+      }
+    }
+  }
+}

+ 1 - 0
entry/src/main/ets/view/LocalMusic.ets

@@ -11858,6 +11858,7 @@ export struct LocalMusic {
             .maxLines(1)
             .fontWeight(FontWeight.Bolder)
             .fontColor(Color.White)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
             .margin({ left: this.currentLyricAlignMode === 0 ? 8 : 0 })
           Row() {
             Text(this.currentSong?.artist !== undefined ? this.currentSong?.artist : '')