Просмотр исходного кода

feat(baidu): 实现百度网盘音频流播放支持

- 新增音频流相关常量配置(STREAMING_USER_AGENT、STREAMING_AUDIO_TYPE)
- 实现buildAudioStreamingUrl函数构建音频流URL
- 添加ensureAudioStreamReady函数确保音频流准备就绪
- 优化Uploader类支持大文件分片上传(4MB分块)
- 扩展日志关键字过滤范围,增加百度相关关键词
- 改进远程驱动管理器以支持音频流播放
- 在LocalMusic组件中集成百度音频流播放逻辑
- 增强错误处理和日志记录机制
- 重构上传逻辑以提高稳定性和可维护性
- 更新百度文件信息接口新增streamUrl字段
chendeben 8 месяцев назад
Родитель
Сommit
1e20aec258

+ 2 - 0
entry/src/main/ets/common/constants/BaiduConstants.ets

@@ -4,6 +4,8 @@ export class BaiduConstants {
   static readonly SECRET_KEY: string = '1qSVRFM34jSmOSdcqqqXKwimlHghU4nc';
   static readonly SIGN_KEY: string = 'jc7sCtsAMp*kzSnIYA%4uCwmbPFF%PF*';
   static readonly USER_AGENT: string = 'pan.baidu.com';
+  static readonly STREAMING_USER_AGENT: string = 'xpanaudio;TTMusic;1.0;openharmony;4.0;ts';
+  static readonly STREAMING_AUDIO_TYPE: string = 'M3U8_HLS_MP3_128';
   static readonly AUTHORIZE_URL: string = 'https://openapi.baidu.com/oauth/2.0/authorize';
   static readonly DEVICE_CODE_URL: string = 'https://openapi.baidu.com/oauth/2.0/device/code';
   static readonly TOKEN_URL: string = 'https://openapi.baidu.com/oauth/2.0/token';

+ 93 - 0
entry/src/main/ets/common/network/BaiduPanClient.ets

@@ -34,6 +34,10 @@ function parseResponseBody(response: http.HttpResponse): string {
   return JSON.stringify(response.result);
 }
 
+function delay(ms: number): Promise<void> {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
 async function httpGet(url: string, params: QueryParamEntry[]): Promise<string> {
   const query = encodeQuery(params);
   const requestUrl = query.length > 0 ? `${url}?${query}` : url;
@@ -251,3 +255,92 @@ export function appendAccessTokenToDlink(dlink: string, accessToken: string): st
   const separator = dlink.includes('?') ? '&' : '?';
   return `${dlink}${separator}access_token=${encodeURIComponent(accessToken)}`;
 }
+
+export function buildAudioStreamingUrl(
+  accessToken: string,
+  path: string,
+  type: string = BaiduConstants.STREAMING_AUDIO_TYPE
+): string {
+  if (!accessToken) {
+    throw new Error('缺少百度网盘access_token');
+  }
+  if (!path) {
+    throw new Error('缺少百度网盘文件路径');
+  }
+  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
+  const params = encodeQuery([
+    { key: 'method', value: 'streaming' },
+    { key: 'access_token', value: accessToken },
+    { key: 'path', value: normalizedPath },
+    { key: 'type', value: type }
+  ]);
+  return `${BaiduConstants.PAN_BASE}/rest/2.0/xpan/file?${params}`;
+}
+
+export async function ensureAudioStreamReady(
+  accessToken: string,
+  path: string,
+  type: string = BaiduConstants.STREAMING_AUDIO_TYPE,
+  maxRetries: number = 5,
+  retryDelayMs: number = 1000
+): Promise<void> {
+  if (!accessToken) {
+    throw new Error('缺少百度网盘access_token');
+  }
+  if (!path) {
+    throw new Error('缺少百度网盘文件路径');
+  }
+  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
+  const query = encodeQuery([
+    { key: 'method', value: 'streaming' },
+    { key: 'access_token', value: accessToken },
+    { key: 'path', value: normalizedPath },
+    { key: 'type', value: type }
+  ]);
+  const requestUrl = `${BaiduConstants.PAN_BASE}/rest/2.0/xpan/file?${query}`;
+  const headers: Record<string, string> = {
+    'User-Agent': BaiduConstants.STREAMING_USER_AGENT,
+    'Host': 'pan.baidu.com'
+  };
+  for (let attempt = 0; attempt < Math.max(maxRetries, 1); attempt++) {
+    const httpRequest = http.createHttp();
+    try {
+      const response = await httpRequest.request(requestUrl, {
+        method: http.RequestMethod.GET,
+        connectTimeout: 8000,
+        readTimeout: 10000,
+        expectDataType: http.HttpDataType.STRING,
+        header: headers
+      });
+      const body = parseResponseBody(response);
+      if (response.responseCode === 200 && typeof body === 'string' && body.includes('#EXTM3U')) {
+        Logger.info(TAG, `百度音频流准备就绪 attempt=${attempt + 1}`);
+        return;
+      }
+      let errno = 0;
+      try {
+        const parsed = JSON.parse(body) as Record<string, number>;
+        if (parsed && typeof parsed.errno === 'number') {
+          errno = parsed.errno;
+        }
+      } catch (_) {
+        // 非JSON响应直接抛错
+      }
+      if (errno === 31341) {
+        Logger.info(TAG, `百度音频流转码中,重试 attempt=${attempt + 1}`);
+        await delay(retryDelayMs);
+        continue;
+      }
+      throw new Error(`百度音频流返回异常 errno=${errno} code=${response.responseCode}`);
+    } catch (error) {
+      if (attempt === maxRetries - 1) {
+        const err = error as Error;
+        throw new Error(err.message);
+      }
+      await delay(retryDelayMs);
+    } finally {
+      httpRequest.destroy();
+    }
+  }
+  throw new Error('百度音频流暂不可用,请稍后重试');
+}

+ 136 - 13
entry/src/main/ets/common/network/Uploader.ets

@@ -13,6 +13,7 @@ export interface UploadConfig {
 export class Uploader {
   private static readonly DEFAULT_UPLOAD_HOST = 'https://up.meitudata.com'
   private static readonly BACKUP_UPLOAD_HOST = 'https://upload.meitudata.com'
+  private static readonly CHUNK_SIZE = 4 * 1024 * 1024; // 4MB 分片上传
 
   /**
    * 检查网络连接和DNS解析
@@ -38,43 +39,165 @@ export class Uploader {
   }
 
   static async uploadFile(filePath: string, config: UploadConfig): Promise<string> {
-    const stat = FileUtil.lstatSync(filePath)
-    const file = FileUtil.openSync(filePath, fs.OpenMode.READ_ONLY)
     try {
-      const buffer = new ArrayBuffer(stat.size)
-      FileUtil.readSync(file.fd, buffer, { offset: 0, length: stat.size })
-      const u8 = new Uint8Array(buffer)
-      const contentBase64 = Base64Util.encodeToStrSync(u8)
+      const stat = FileUtil.lstatSync(filePath)
       const key = Uploader.buildKey(filePath, config)
       const keyBase64 = Uploader.toUrlSafeBase64(key)
       const mainHost = config.uploadHost ?? Uploader.DEFAULT_UPLOAD_HOST
 
       LogUtil.info('Uploader', `开始上传文件: ${filePath}, 大小: ${stat.size} bytes`)
-      LogUtil.info('Uploader', `使用上传主机: ${mainHost}`)
 
-      // 对于 401 错误,先尝试直接上传不做网络预检查
       try {
         LogUtil.info('Uploader', `直接尝试主主机上传: ${mainHost}`)
-        return await Uploader.doUpload(mainHost, stat.size, key, keyBase64, contentBase64, config)
+        return await Uploader.performUploadWithHost(mainHost, filePath, stat.size, key, keyBase64, config)
       } catch (err) {
         LogUtil.warn('Uploader', `主上传Host失败(${mainHost}): ${(err as Error).message},尝试预检查后使用备用`)
-
-        // 预检查主机的网络连接性
         const backupHostReachable = await Uploader.checkNetworkConnectivity(Uploader.BACKUP_UPLOAD_HOST)
         if (!backupHostReachable) {
           throw new Error(`上传失败且备用主机不可达: ${(err as Error).message}`)
         }
         LogUtil.info('Uploader', `使用备用主机上传: ${Uploader.BACKUP_UPLOAD_HOST}`)
-        return await Uploader.doUpload(Uploader.BACKUP_UPLOAD_HOST, stat.size, key, keyBase64, contentBase64, config)
+        return await Uploader.performUploadWithHost(Uploader.BACKUP_UPLOAD_HOST, filePath, stat.size, key, keyBase64, config)
       }
     } catch (err) {
       LogUtil.error('Uploader', `上传异常: ${(err as Error).message}`)
       throw new Error((err as Error).message)
+    }
+  }
+
+  private static async performUploadWithHost(
+    host: string,
+    filePath: string,
+    fileSize: number,
+    key: string,
+    keyBase64: string,
+    config: UploadConfig
+  ): Promise<string> {
+    LogUtil.info('Uploader', `使用上传主机: ${host}`)
+    if (fileSize > Uploader.CHUNK_SIZE) {
+      LogUtil.info('Uploader', `文件较大,启用分片上传: ${fileSize} bytes (chunk=${Uploader.CHUNK_SIZE})`)
+      return await Uploader.uploadFileChunked(host, filePath, fileSize, key, keyBase64, config)
+    }
+    const contentBase64 = await Uploader.readFileAsBase64(filePath, fileSize)
+    return await Uploader.doUploadBase64(host, fileSize, key, keyBase64, contentBase64, config)
+  }
+
+  private static async readFileAsBase64(filePath: string, size: number): Promise<string> {
+    const file = FileUtil.openSync(filePath, fs.OpenMode.READ_ONLY)
+    try {
+      const buffer = new ArrayBuffer(size)
+      FileUtil.readSync(file.fd, buffer, { offset: 0, length: size })
+      const u8 = new Uint8Array(buffer)
+      return Base64Util.encodeToStrSync(u8)
     } finally {
       FileUtil.closeSync(file.fd)
     }
   }
 
+  private static async uploadFileChunked(
+    host: string,
+    filePath: string,
+    fileSize: number,
+    key: string,
+    keyBase64: string,
+    config: UploadConfig
+  ): Promise<string> {
+    const contexts: string[] = []
+    const file = FileUtil.openSync(filePath, fs.OpenMode.READ_ONLY)
+    const totalChunks = Math.ceil(fileSize / Uploader.CHUNK_SIZE)
+    let offset = 0
+    let chunkIndex = 0
+    try {
+      while (offset < fileSize) {
+        const remaining = fileSize - offset
+        const chunkSize = Math.min(remaining, Uploader.CHUNK_SIZE)
+        const buffer = new ArrayBuffer(chunkSize)
+        FileUtil.readSync(file.fd, buffer, { offset, length: chunkSize })
+        chunkIndex++
+        LogUtil.info('Uploader', `上传分片 ${chunkIndex}/${totalChunks}, 偏移: ${offset}, 大小: ${chunkSize}`)
+        const ctx = await Uploader.uploadChunk(host, buffer, chunkSize, config)
+        contexts.push(ctx)
+        offset += chunkSize
+      }
+    } finally {
+      FileUtil.closeSync(file.fd)
+    }
+    return await Uploader.finalizeChunks(host, fileSize, key, keyBase64, contexts, config)
+  }
+
+  private static async uploadChunk(
+    host: string,
+    chunkData: ArrayBuffer,
+    chunkSize: number,
+    config: UploadConfig
+  ): Promise<string> {
+    const url = `${host}/mkblk/${chunkSize}`
+    const httpRequest = http.createHttp()
+    try {
+      const options: http.HttpRequestOptions = {
+        method: http.RequestMethod.POST,
+        connectTimeout: 10000,
+        readTimeout: 20000,
+        expectDataType: http.HttpDataType.STRING,
+        header: {
+          'Content-Type': 'application/octet-stream',
+          'Authorization': `UpToken ${config.uploadToken}`,
+          'User-Agent': 'MZXJ/1.0'
+        },
+        extraData: chunkData
+      }
+      const response = await httpRequest.request(url, options)
+      if (response.responseCode !== 200) {
+        throw new Error(`分片上传失败: HTTP ${response.responseCode}`)
+      }
+      const body = JSON.parse(response.result as string) as Record<string, string>
+      const ctx = body['ctx']
+      if (!ctx) {
+        throw new Error('分片上传返回无ctx')
+      }
+      return ctx
+    } finally {
+      httpRequest.destroy()
+    }
+  }
+
+  private static async finalizeChunks(
+    host: string,
+    fileSize: number,
+    key: string,
+    keyBase64: string,
+    contexts: string[],
+    config: UploadConfig
+  ): Promise<string> {
+    const url = `${host}/mkfile/${fileSize}/key/${encodeURIComponent(keyBase64)}`
+    const body = contexts.join(',')
+    const httpRequest = http.createHttp()
+    try {
+      const options: http.HttpRequestOptions = {
+        method: http.RequestMethod.POST,
+        connectTimeout: 10000,
+        readTimeout: 20000,
+        expectDataType: http.HttpDataType.STRING,
+        header: {
+          'Content-Type': 'text/plain',
+          'Authorization': `UpToken ${config.uploadToken}`,
+          'User-Agent': 'MZXJ/1.0'
+        },
+        extraData: body
+      }
+      const response = await httpRequest.request(url, options)
+      if (response.responseCode !== 200) {
+        throw new Error(`mkfile 失败: HTTP ${response.responseCode}`)
+      }
+      const result = JSON.parse(response.result as string) as Record<string, string>
+      const savedKey = result['key'] ?? key
+      const domain = Uploader.normalizeDomain(config.domain)
+      return `${domain}/${savedKey}`
+    } finally {
+      httpRequest.destroy()
+    }
+  }
+
   private static buildKey(filePath: string, config: UploadConfig): string {
     const prefix = config.keyPrefix ?? 'ttmusic-logs'
     const filename = FileUtil.getFileName(filePath)
@@ -96,7 +219,7 @@ export class Uploader {
     return domain
   }
 
-  private static async doUpload(host: string, size: number, key: string, keyBase64: string, contentBase64: string, config: UploadConfig): Promise<string> {
+  private static async doUploadBase64(host: string, size: number, key: string, keyBase64: string, contentBase64: string, config: UploadConfig): Promise<string> {
     const url = `${host}/putb64/${size}/key/${encodeURIComponent(keyBase64)}`
 
     // 调试信息

+ 25 - 7
entry/src/main/ets/common/util/Logger.ets

@@ -17,7 +17,21 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
 import { ServerLogUtil } from './ServerLogUtil';
 
 type LogLevel = 'debug' | 'info' | 'warn' | 'error';
-const SERVER_LOG_KEYWORDS: Array<string> = ['webdav', 'remotedrive', 'account', 'navidrome', 'upload'];
+const SERVER_LOG_KEYWORDS: Array<string> = [
+  'webdav',
+  'remotedrive',
+  'account',
+  'navidrome',
+  'upload',
+  'localmusic',
+  'baidu',
+  '百度',
+  'ftp',
+  'smb',
+  'cache',
+  'remote',
+  'pan'
+];
 
 class Logger {
   private domain: number;
@@ -60,11 +74,11 @@ class Logger {
       return;
     }
     const tag = args.length > 0 ? args[0] : '';
-    if (!tag || !this.shouldForward(tag)) {
+    const normalizedMessage = args.slice(1).map(item => `${item ?? ''}`).join(' ').trim();
+    if (!tag || normalizedMessage.length === 0) {
       return;
     }
-    const normalizedMessage = args.slice(1).map(item => `${item ?? ''}`).join(' ').trim();
-    if (normalizedMessage.length === 0) {
+    if (!this.shouldForward(tag, normalizedMessage)) {
       return;
     }
     switch (level) {
@@ -85,9 +99,13 @@ class Logger {
     }
   }
 
-  private shouldForward(tag: string): boolean {
-    const lower = tag.toLowerCase();
-    return SERVER_LOG_KEYWORDS.some(keyword => lower.includes(keyword));
+  private shouldForward(tag: string, message: string): boolean {
+    const lowerTag = tag.toLowerCase();
+    if (SERVER_LOG_KEYWORDS.some(keyword => lowerTag.includes(keyword))) {
+      return true;
+    }
+    const lowerMessage = message.toLowerCase();
+    return SERVER_LOG_KEYWORDS.some(keyword => lowerMessage.includes(keyword));
   }
 }
 

+ 21 - 6
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -23,7 +23,7 @@ import { WebDavUrlUtil } from './WebDavUrlUtil';
 import MediaTable from './MediaTable';
 import { FtpClient, FileInfo as FtpEntryInfo, StringEncoding, AccessOptions } from '@liuzhosoft/ftp4h';
 import { BaiduConstants } from '../constants/BaiduConstants';
-import { appendAccessTokenToDlink, BaiduListEntry, BaiduFileMeta, deleteFiles as deleteBaiduFiles, fetchFileMetas as fetchBaiduFileMetas, listDirectory as listBaiduDirectory, refreshAccessToken as refreshBaiduAccessToken } from '../network/BaiduPanClient';
+import { appendAccessTokenToDlink, BaiduListEntry, BaiduFileMeta, buildAudioStreamingUrl, deleteFiles as deleteBaiduFiles, ensureAudioStreamReady, fetchFileMetas as fetchBaiduFileMetas, listDirectory as listBaiduDirectory, refreshAccessToken as refreshBaiduAccessToken } from '../network/BaiduPanClient';
 import { ServerLogUtil } from './ServerLogUtil';
 
 const TAG = 'heanup RemoteDriveManager';
@@ -56,6 +56,7 @@ export interface StreamAuthInfo {
 export interface BaiduFileInfo {
   size: number;
   dlink: string;
+  streamUrl: string;
 }
 
 export interface TransferTask {
@@ -354,11 +355,11 @@ export class RemoteDriveManager {
     }
 
     if (currentSong.type === CommonConstants.TYPE_BAIDU) {
-      headers.set('User-Agent', BaiduConstants.USER_AGENT);
+      headers.set('User-Agent', BaiduConstants.STREAMING_USER_AGENT);
+      headers.set('Host', 'pan.baidu.com');
       headers.set('Accept', '*/*');
       headers.set('Connection', 'Keep-Alive');
       headers.set('Referer', 'https://pan.baidu.com/disk/home');
-      headers.set('Range', 'bytes=0-');
       headers.set('Accept-Encoding', 'identity');
       headers.set('Pragma', 'no-cache');
       headers.set('Cache-Control', 'no-cache');
@@ -1770,7 +1771,7 @@ export class RemoteDriveManager {
     return urlWithToken;
   }
 
-  public async getBaiduFileInfo(account: WebDavAccount, fsId: string): Promise<BaiduFileInfo> {
+  public async getBaiduFileInfo(account: WebDavAccount, fsId: string, remotePath?: string): Promise<BaiduFileInfo> {
     const accessToken = await this.ensureBaiduAccessToken(account);
     if (!fsId) {
       throw new Error('缺少百度网盘文件fs_id');
@@ -1786,12 +1787,26 @@ export class RemoteDriveManager {
     }
     const rawLink = meta.dlink.startsWith('https://') ? meta.dlink : meta.dlink.replace('http://', 'https://');
     const urlWithToken = appendAccessTokenToDlink(rawLink, accessToken);
-    Logger.info(TAG, `百度网盘文件信息获取成功: fsId=${fsId}, size=${meta.size}, dlink=${urlWithToken}`);
+    let streamUrl = urlWithToken;
+    if (remotePath) {
+      try {
+        await ensureAudioStreamReady(accessToken, remotePath);
+        streamUrl = buildAudioStreamingUrl(accessToken, remotePath);
+        Logger.info(TAG, `百度网盘文件信息获取成功: fsId=${fsId}, size=${meta.size}, streamUrl=${streamUrl}`);
+      } catch (streamError) {
+        const err = streamError as Error;
+        Logger.warn(TAG, `百度音频流暂不可用,回退到下载链接: ${err.message}`);
+        streamUrl = urlWithToken;
+      }
+    } else {
+      Logger.warn(TAG, `缺少百度网盘文件路径,使用下载链接播放: fsId=${fsId}`);
+    }
     // 缓存 dlink
     this.cacheBaiduDlink(account, fsId, accessToken, urlWithToken);
     const result: BaiduFileInfo = {
       size: meta.size,
-      dlink: urlWithToken
+      dlink: urlWithToken,
+      streamUrl
     };
     return result;
   }

+ 12 - 5
entry/src/main/ets/view/LocalMusic.ets

@@ -624,6 +624,8 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
         throw new Error('百度网盘账户不可用');
       }
       Logger.info(TAG, `百度网盘播放准备 - accountId: ${account.id}, fsId: ${song.baiduFsId || song.id}`);
+      const sanitizedAccount = ServerLogUtil.sanitizeAccount(account);
+      void ServerLogUtil.info('BaiduStream', `准备播放百度歌曲: account=${sanitizedAccount}, fsId=${song.baiduFsId || song.id}`);
 
       const fsId = song.baiduFsId || song.id;
       if (!fsId) {
@@ -632,6 +634,7 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
 
       // 优先使用remote_rel_path,回退到文件名
       const relativePath = song.remote_rel_path || song.name || song.fileName || song.filePath;
+      const streamPath = song.remote_rel_path;
       const cachedPath = await resolveCacheFilePath(
         RemoteCacheType.BAIDU,
         account.id?.toString(),
@@ -650,16 +653,18 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
 
       // 缓存不存在或无效,获取文件信息(包含大小和下载链接)
       Logger.info(TAG, `百度网盘文件未缓存,准备获取文件信息: ${relativePath}`);
-      const fileInfo = await manager.getBaiduFileInfo(account, fsId);
-      Logger.info(TAG, `百度网盘文件信息获取成功: 大小=${fileInfo.size}字节,链接已获取`);
+      const fileInfo = await manager.getBaiduFileInfo(account, fsId, streamPath);
+      Logger.info(TAG, `百度网盘文件信息获取成功: 大小=${fileInfo.size}字节,流地址已获取`);
+      void ServerLogUtil.info('BaiduStream', `音频流URL获取成功: ${fileInfo.streamUrl}`);
 
-      // 立即返回下载链接,开始播放
-      Logger.info(TAG, `返回下载链接进行播放,同时后台缓存: ${fileInfo.dlink}`);
+      // 立即返回媒体流地址,开始播放
+      Logger.info(TAG, `返回音频流链接进行播放,同时后台缓存: ${fileInfo.streamUrl}`);
 
       // 异步进行缓存,不阻塞播放
       void (async () => {
         try {
           Logger.info(TAG, `后台开始缓存百度网盘文件: ${relativePath},大小: ${fileInfo.size}字节`);
+          void ServerLogUtil.info('BaiduStream', `开始缓存百度文件: account=${sanitizedAccount}, path=${relativePath}`);
           const cachedFilePath = await ensureBaiduFileCached(
             account,
             fsId,
@@ -668,14 +673,16 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
             fileInfo.size
           );
           Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
+          void ServerLogUtil.info('BaiduStream', `缓存完成: ${cachedFilePath}`);
           void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
         } catch (cacheError) {
           const cacheErr = cacheError as Error;
           Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
+          void ServerLogUtil.error('BaiduStream', `缓存失败: ${cacheErr.message}`);
         }
       })();
 
-      return fileInfo.dlink;
+      return fileInfo.streamUrl;
     } catch (error) {
       const err = error as Error;
       Logger.error(TAG, `百度网盘播放链接获取失败: ${err.message}`);