Эх сурвалжийг харах

feat(smb): 实现SMB文件流式播放功能

chendeben 8 сар өмнө
parent
commit
878f4590d0

+ 105 - 1
entry/src/main/cpp/napi_init.cpp

@@ -685,6 +685,109 @@ napi_value DownloadSmbFile(napi_env env, napi_callback_info info)
         return nullptr;
     }
 }
+
+napi_value ReadSmbFileRange(napi_env env, napi_callback_info info)
+{
+    constexpr size_t MAX_RANGE_SIZE = 2 * 1024 * 1024;
+    constexpr size_t BUFFER_SIZE = 64 * 1024;
+    try {
+        size_t argc = 8;
+        napi_value args[8] = {nullptr};
+        NapiCheck(napi_get_cb_info(env, info, &argc, args, nullptr, nullptr), "Failed to get arguments for readSmbFileRange");
+        if (argc < 8) {
+            throw std::runtime_error("readSmbFileRange requires host, share, username, password, domain, remotePath, offset and length");
+        }
+
+        std::string host = ReadString(env, args[0], "host", true);
+        std::string share = ReadString(env, args[1], "share", true);
+        std::string username = ReadString(env, args[2], "username", true);
+        std::string password = ReadString(env, args[3], "password", true);
+        std::string domain = ReadString(env, args[4], "domain", false);
+        std::string remotePath = ReadString(env, args[5], "remotePath", true);
+        int64_t offsetValue = ReadInt64(env, args[6], "offset");
+        int64_t lengthValue = ReadInt64(env, args[7], "length");
+        if (offsetValue < 0) {
+            offsetValue = 0;
+        }
+        if (lengthValue <= 0) {
+            napi_value emptyResult = nullptr;
+            void *tmp = nullptr;
+            NapiCheck(napi_create_arraybuffer(env, 0, &tmp, &emptyResult), "Failed to create empty buffer");
+            return emptyResult;
+        }
+        size_t requestedLength = static_cast<size_t>(lengthValue);
+        if (requestedLength > MAX_RANGE_SIZE) {
+            requestedLength = MAX_RANGE_SIZE;
+        }
+
+        smb2_context *ctx = smb2_init_context();
+        if (ctx == nullptr) {
+            throw std::runtime_error("Unable to initialize libsmb2 context");
+        }
+        auto cleanupContext = [&ctx]() {
+            if (ctx != nullptr) {
+                smb2_disconnect_share(ctx);
+                smb2_destroy_context(ctx);
+                ctx = nullptr;
+            }
+        };
+
+        smb2_set_timeout(ctx, 30);
+        smb2_set_authentication(ctx, SMB2_SEC_NTLMSSP);
+        smb2_set_user(ctx, username.c_str());
+        smb2_set_password(ctx, password.c_str());
+        smb2_set_domain(ctx, domain.empty() ? nullptr : domain.c_str());
+
+        int rc = smb2_connect_share(ctx, host.c_str(), share.c_str(), username.c_str());
+        if (rc != 0) {
+            cleanupContext();
+            throw std::runtime_error(BuildSmbErrorMessage(ctx, "Failed to connect to share"));
+        }
+
+        std::string normalizedRemotePath = NormalizeRemotePath(remotePath);
+        smb2fh *fileHandle = smb2_open(ctx, normalizedRemotePath.c_str(), O_RDONLY);
+        if (fileHandle == nullptr) {
+            cleanupContext();
+            throw std::runtime_error(BuildSmbErrorMessage(ctx, "Failed to open remote file"));
+        }
+
+        std::vector<uint8_t> buffer(requestedLength);
+        size_t totalRead = 0;
+        while (totalRead < requestedLength) {
+            const size_t remaining = requestedLength - totalRead;
+            const uint32_t toRead = static_cast<uint32_t>(std::min<size_t>(BUFFER_SIZE, remaining));
+            int bytesRead = smb2_pread(ctx, fileHandle, buffer.data() + totalRead, toRead,
+                                       static_cast<uint64_t>(offsetValue) + totalRead);
+            if (bytesRead < 0) {
+                std::string message = BuildSmbErrorMessage(ctx, "Failed to read remote range");
+                smb2_close(ctx, fileHandle);
+                cleanupContext();
+                throw std::runtime_error(message);
+            }
+            if (bytesRead == 0) {
+                break;
+            }
+            totalRead += static_cast<size_t>(bytesRead);
+            if (static_cast<uint32_t>(bytesRead) < toRead) {
+                break;
+            }
+        }
+
+        smb2_close(ctx, fileHandle);
+        cleanupContext();
+
+        napi_value bufferValue = nullptr;
+        void *outData = nullptr;
+        NapiCheck(napi_create_arraybuffer(env, totalRead, &outData, &bufferValue), "Failed to create range buffer");
+        if (totalRead > 0) {
+            std::memcpy(outData, buffer.data(), totalRead);
+        }
+        return bufferValue;
+    } catch (const std::exception &error) {
+        napi_throw_error(env, nullptr, error.what());
+        return nullptr;
+    }
+}
 }
 
 EXTERN_C_START
@@ -698,7 +801,8 @@ static napi_value Init(napi_env env, napi_value exports)
         {"connectTree", nullptr, ConnectTree, nullptr, nullptr, nullptr, napi_default, nullptr},
         {"disconnectTree", nullptr, DisconnectTree, nullptr, nullptr, nullptr, napi_default, nullptr},
         {"readDirectory", nullptr, ReadDirectory, nullptr, nullptr, nullptr, napi_default, nullptr},
-        {"downloadSmbFile", nullptr, DownloadSmbFile, nullptr, nullptr, nullptr, napi_default, nullptr}
+        {"downloadSmbFile", nullptr, DownloadSmbFile, nullptr, nullptr, nullptr, napi_default, nullptr},
+        {"readSmbFileRange", nullptr, ReadSmbFileRange, nullptr, nullptr, nullptr, napi_default, nullptr}
     };
     NapiCheck(napi_define_properties(env, exports, sizeof(descriptors) / sizeof(descriptors[0]), descriptors), "Failed to define native exports");
     return exports;

+ 91 - 0
entry/src/main/ets/common/network/FtpRangeReader.ets

@@ -0,0 +1,91 @@
+import { FtpClient, AccessOptions, StringEncoding } from '@liuzhosoft/ftp4h';
+import Logger from '../util/Logger';
+
+export interface FtpConnectionInfo {
+  host: string;
+  port: number;
+  username?: string;
+  password?: string;
+  encoding: StringEncoding;
+}
+
+export async function readFtpRange(
+  connection: FtpConnectionInfo,
+  remotePath: string,
+  offset: number,
+  length: number
+): Promise<ArrayBuffer> {
+  if (length <= 0) {
+    Logger.warn('FtpRangeReader', 'length <= 0,返回空buffer');
+    return new ArrayBuffer(0);
+  }
+
+  const safeOffset = Math.max(0, Math.floor(offset));
+  const safeLength = Math.max(0, Math.floor(length));
+
+  const path = remotePath.startsWith('/') ? remotePath : `/${remotePath}`;
+  Logger.info('FtpRangeReader', `开始读取FTP范围: host=${connection.host}, port=${connection.port}, path=${path}, offset=${safeOffset}, length=${safeLength}`);
+
+  const client = new FtpClient();
+  const options: AccessOptions = {
+    host: connection.host,
+    port: connection.port,
+    user: connection.username,
+    password: connection.password,
+    encoding: connection.encoding
+  };
+
+  try {
+    await client.access(options);
+    Logger.info('FtpRangeReader', 'FTP连接成功');
+
+    const chunks: Uint8Array[] = [];
+    let received = 0;
+
+    await client.read(
+      path,
+      (data: ArrayBuffer) => {
+        if (!data || data.byteLength === 0) {
+          return;
+        }
+        const chunk = new Uint8Array(data);
+        received += chunk.byteLength;
+        chunks.push(chunk);
+      },
+      undefined,
+      safeOffset,
+      safeLength
+    );
+
+    if (received === 0) {
+      Logger.warn('FtpRangeReader', 'FTP范围请求未返回数据');
+      return new ArrayBuffer(0);
+    }
+
+    const total = Math.min(received, safeLength);
+    const result = new Uint8Array(total);
+    let offsetInResult = 0;
+    for (let i = 0; i < chunks.length && offsetInResult < total; i++) {
+      const chunk = chunks[i];
+      const remaining = total - offsetInResult;
+      const copyLength = Math.min(chunk.byteLength, remaining);
+      result.set(chunk.subarray(0, copyLength), offsetInResult);
+      offsetInResult += copyLength;
+    }
+
+    Logger.info('FtpRangeReader', `FTP范围读取完成: ${total} bytes`);
+    return result.buffer;
+  } catch (error) {
+    const err = error instanceof Error ? error : new Error(String(error));
+    Logger.error('FtpRangeReader', `读取FTP范围失败: ${err.message}, path=${remotePath}, host=${connection.host}`);
+    Logger.error('FtpRangeReader', `错误堆栈: ${err.stack || 'no stack'}`);
+    throw err;
+  } finally {
+    try {
+      await client.close();
+      Logger.info('FtpRangeReader', 'FTP连接已关闭');
+    } catch (closeError) {
+      Logger.warn('FtpRangeReader', `关闭FTP连接时出错: ${(closeError as Error).message}`);
+    }
+  }
+}

+ 143 - 0
entry/src/main/ets/common/network/SmbFileCache.ets

@@ -5,6 +5,10 @@ import Logger from '../util/Logger';
 import nativeBridge from 'libentry.so';
 import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
 import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
+import { taskpool } from '@kit.ArkTS';
+import SmbStreamingHttpServer from './SmbStreamingHttpServer';
+import { fileIo } from '@kit.CoreFileKit';
+import { SmbConnectionInfo } from './SmbRangeReader';
 
 interface SmbDownloadBinding {
   downloadSmbFile(host: string, share: string, username: string, password: string, domain: string | undefined, remotePath: string, localPath: string): void;
@@ -12,6 +16,27 @@ interface SmbDownloadBinding {
 
 const smbBinding: SmbDownloadBinding = nativeBridge as SmbDownloadBinding;
 
+enum DownloadState {
+  Pending = 'pending',
+  Completed = 'completed',
+  Failed = 'failed'
+}
+
+interface DownloadTask {
+  promise: Promise<void>;
+  state: DownloadState;
+}
+
+const ongoingDownloads: Map<string, DownloadTask> = new Map();
+
+function handleSmbStreamingDownloadError(key: string, error: Error): void {
+  Logger.error('SMB streaming download failed:', error.message);
+  const current = ongoingDownloads.get(key);
+  if (current) {
+    current.state = DownloadState.Failed;
+  }
+}
+
 function cleanShareName(name?: string): string {
   return name ? name.replace(/^\/+|\/+$/g, '') : '';
 }
@@ -155,3 +180,121 @@ RemoteCacheManager.registerStrategy(new SmbCacheStrategy());
 export async function ensureSmbFileCached(account: WebDavAccount, relativePath: string): Promise<string> {
   return RemoteCacheManager.ensureCached(RemoteCacheType.SMB, { account, remotePath: relativePath });
 }
+
+@Concurrent
+function smbDownloadWorker(
+  host: string,
+  share: string,
+  username: string,
+  password: string,
+  domain: string,
+  remotePath: string,
+  localPath: string
+): void {
+  const binding: SmbDownloadBinding = nativeBridge as SmbDownloadBinding;
+  binding.downloadSmbFile(
+    host,
+    share,
+    username,
+    password,
+    domain,
+    remotePath,
+    localPath
+  );
+}
+
+async function startStreamingDownload(account: WebDavAccount, remotePath: string, localPath: string): Promise<void> {
+  const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+  if (!host) {
+    throw new Error('SMB账号缺少服务器地址');
+  }
+  const task = new taskpool.Task(
+    smbDownloadWorker,
+    host,
+    account.smbShare,
+    account.account,
+    account.password,
+    account.smbDomain ?? '',
+    remotePath,
+    localPath
+  );
+  await taskpool.execute(task, taskpool.Priority.HIGH);
+}
+
+export interface SmbStreamingMeta {
+  fileSize?: number;
+  mimeType?: string;
+  fileName?: string;
+}
+
+export async function ensureSmbFileStreaming(account: WebDavAccount, relativePath: string, meta?: SmbStreamingMeta): Promise<string> {
+  if (!account.smbShare || account.smbShare.trim().length === 0) {
+    throw new Error('SMB账户缺少共享名称');
+  }
+  const normalizedRelative = normalizeCacheRelativePath(relativePath);
+  const pathInfo = await resolveCacheFilePath(RemoteCacheType.SMB, account.id?.toString(), normalizedRelative);
+  const cachePath = pathInfo.cachePath;
+  const key = `${account.id || 0}:${pathInfo.normalizedRelative}`;
+
+  const exists = await FileManager.isExist(cachePath);
+  let existingSize = 0;
+  if (exists) {
+    try {
+      existingSize = await FileManager.getFileSize(cachePath);
+    } catch (error) {
+      Logger.warn('SMB streaming get size failed:', (error as Error).message);
+    }
+  }
+  const expectedSize = meta?.fileSize;
+  const needsDownload = !exists || existingSize <= 0 || (expectedSize !== undefined && existingSize < expectedSize);
+
+  let downloadTask = ongoingDownloads.get(key);
+  if (needsDownload) {
+    if (!downloadTask || downloadTask.state === DownloadState.Failed) {
+      const promise = startStreamingDownload(account, pathInfo.normalizedRelative, cachePath)
+        .then(() => {
+          const current = ongoingDownloads.get(key);
+          if (current) {
+            current.state = DownloadState.Completed;
+          }
+        })
+        .catch(handleSmbStreamingDownloadError.bind(null, key));
+      downloadTask = { promise, state: DownloadState.Pending };
+      ongoingDownloads.set(key, downloadTask);
+    }
+  } else if (downloadTask && downloadTask.state === DownloadState.Pending) {
+    downloadTask.state = DownloadState.Completed;
+  }
+
+  if (!exists) {
+    try {
+      const file = fileIo.openSync(cachePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
+      fileIo.closeSync(file);
+    } catch (error) {
+      Logger.warn('SMB streaming failed to prepare cache file:', (error as Error).message);
+    }
+  }
+
+  const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+  if (!host) {
+    throw new Error('SMB账号缺少服务器地址');
+  }
+  const streamingServer = SmbStreamingHttpServer.getInstance();
+  const smbConnection: SmbConnectionInfo = {
+    host,
+    share: account.smbShare,
+    username: account.account,
+    password: account.password,
+    domain: account.smbDomain
+  };
+  return streamingServer.getStreamingUrl({
+    sessionKey: key,
+    cachePath,
+    expectedSize: meta?.fileSize,
+    mimeType: meta?.mimeType,
+    fileName: meta?.fileName || pathInfo.normalizedRelative,
+    smbConnection,
+    remotePath: pathInfo.normalizedRelative,
+    protocol: 'smb'
+  });
+}

+ 47 - 0
entry/src/main/ets/common/network/SmbRangeReader.ets

@@ -0,0 +1,47 @@
+import nativeBridge from 'libentry.so';
+
+interface SmbRangeBinding {
+  readSmbFileRange(
+    host: string,
+    share: string,
+    username: string,
+    password: string,
+    domain: string,
+    remotePath: string,
+    offset: number,
+    length: number
+  ): ArrayBuffer;
+}
+
+export interface SmbConnectionInfo {
+  host: string;
+  share: string;
+  username: string;
+  password: string;
+  domain?: string;
+}
+
+const rangeBinding: SmbRangeBinding = nativeBridge as SmbRangeBinding;
+
+export function readSmbRange(
+  connection: SmbConnectionInfo,
+  remotePath: string,
+  offset: number,
+  length: number
+): ArrayBuffer {
+  if (length <= 0) {
+    return new ArrayBuffer(0);
+  }
+  const safeOffset = Math.max(0, Math.floor(offset));
+  const safeLength = Math.max(0, Math.floor(length));
+  return rangeBinding.readSmbFileRange(
+    connection.host,
+    connection.share,
+    connection.username,
+    connection.password,
+    connection.domain ?? '',
+    remotePath,
+    safeOffset,
+    safeLength
+  );
+}

+ 470 - 0
entry/src/main/ets/common/network/SmbStreamingHttpServer.ets

@@ -0,0 +1,470 @@
+import Logger from '../util/Logger';
+import { fileIo } from '@kit.CoreFileKit';
+import { BusinessError } from '@kit.BasicServicesKit';
+import { httpServer, HttpRequest, HttpResponse } from '@webabcd/harmony-httpserver';
+import { readSmbRange, SmbConnectionInfo } from './SmbRangeReader';
+import { readFtpRange, FtpConnectionInfo } from './FtpRangeReader';
+
+const TAG = 'SmbStreamingHttpServer';
+const STREAM_ROUTE_PREFIX = '/smb-stream';
+const LOCAL_HOST = '127.0.0.1';
+const MAX_CHUNK_SIZE = 512 * 1024; // 512KB per response
+const RANGE_WAIT_TIMEOUT_MS = 60000;
+const RANGE_POLL_INTERVAL_MS = 100;
+const SESSION_TTL_MS = 10 * 60 * 1000;
+const PORT_CANDIDATES: number[] = [18888, 18889, 18900, 18901, 18902];
+
+type StreamingProtocol = 'smb' | 'ftp';
+
+interface StreamingSessionOptions {
+  sessionKey: string;
+  cachePath: string;
+  expectedSize?: number;
+  mimeType?: string;
+  fileName?: string;
+  remotePath?: string;
+  protocol?: StreamingProtocol;
+  smbConnection?: SmbConnectionInfo;
+  ftpConnection?: FtpConnectionInfo;
+}
+
+interface StreamingSession extends StreamingSessionOptions {
+  lastAccess: number;
+}
+
+interface RangeInfo {
+  start: number;
+  end?: number;
+  isRange: boolean;
+}
+
+interface ChunkResult {
+  buffer: ArrayBuffer;
+  start: number;
+  end: number;
+  total?: number;
+}
+
+class RangeNotSatisfiableError extends Error {
+  availableRange: string;
+
+  constructor(availableRange: string) {
+    super('Requested Range Not Satisfiable');
+    this.name = 'RangeNotSatisfiableError';
+    this.availableRange = availableRange;
+  }
+}
+
+export default class SmbStreamingHttpServer {
+  private static instance?: SmbStreamingHttpServer;
+  private port: number = -1;
+  private sessions: Map<string, StreamingSession> = new Map();
+  private startPromise?: Promise<void>;
+
+  static getInstance(): SmbStreamingHttpServer {
+    if (!SmbStreamingHttpServer.instance) {
+      SmbStreamingHttpServer.instance = new SmbStreamingHttpServer();
+    }
+    return SmbStreamingHttpServer.instance;
+  }
+
+  async getStreamingUrl(options: StreamingSessionOptions): Promise<string> {
+    await this.ensureServerStarted();
+    const now = Date.now();
+    const existing = this.sessions.get(options.sessionKey);
+    if (existing) {
+      existing.lastAccess = now;
+      existing.cachePath = options.cachePath;
+      existing.expectedSize = options.expectedSize;
+      existing.mimeType = options.mimeType;
+      existing.fileName = options.fileName;
+      existing.remotePath = options.remotePath;
+      existing.protocol = options.protocol;
+      existing.smbConnection = options.smbConnection;
+      existing.ftpConnection = options.ftpConnection;
+      Logger.debug(TAG, `更新SMB流会话: ${options.sessionKey}`);
+    } else {
+      this.sessions.set(options.sessionKey, {
+        sessionKey: options.sessionKey,
+        cachePath: options.cachePath,
+        expectedSize: options.expectedSize,
+        mimeType: options.mimeType,
+        fileName: options.fileName,
+        remotePath: options.remotePath,
+        protocol: options.protocol,
+        smbConnection: options.smbConnection,
+        ftpConnection: options.ftpConnection,
+        lastAccess: now
+      });
+      Logger.info(TAG, `注册SMB流会话: ${options.sessionKey}`);
+    }
+    this.cleanupExpiredSessions();
+    return `http://${LOCAL_HOST}:${this.port}${STREAM_ROUTE_PREFIX}/${encodeURIComponent(options.sessionKey)}`;
+  }
+
+  private async ensureServerStarted(): Promise<void> {
+    if (this.port > 0) {
+      return;
+    }
+    if (this.startPromise) {
+      return this.startPromise;
+    }
+    this.startPromise = this.startServerInternal().finally(() => {
+      this.startPromise = undefined;
+    });
+    return this.startPromise;
+  }
+
+  private async startServerInternal(): Promise<void> {
+    httpServer.enableLog(false);
+    this.port = await this.tryStartOnAvailablePort();
+    httpServer.handleHttpRequestAsync((request: HttpRequest) => this.handleRequest(request));
+    Logger.info(TAG, `SMB流HTTP服务启动,端口: ${this.port}`);
+  }
+
+  private async tryStartOnAvailablePort(): Promise<number> {
+    for (let i = 0; i < PORT_CANDIDATES.length; i++) {
+      const candidate = PORT_CANDIDATES[i];
+      try {
+        return await this.startOnPort(candidate);
+      } catch (error) {
+        const err = error as Error;
+        Logger.warn(TAG, `端口${candidate}启动失败: ${err.message}`);
+      }
+    }
+    throw new Error('无法启动SMB流HTTP服务,端口均不可用');
+  }
+
+  private startOnPort(port: number): Promise<number> {
+    return new Promise((resolve, reject) => {
+      try {
+        httpServer.start(port, (error: BusinessError, realPort: number) => {
+          if (error && error.code !== 0) {
+            reject(new Error(`HTTP服务启动失败:${error.code}-${error.message}`));
+            return;
+          }
+          resolve(realPort);
+        });
+      } catch (error) {
+        reject(error as Error);
+      }
+    });
+  }
+
+  private async handleRequest(request: HttpRequest): Promise<HttpResponse> {
+    try {
+      this.cleanupExpiredSessions();
+      if (!request || !request.url) {
+        return this.buildPlainResponse(400, 'invalid request');
+      }
+      const sessionId = this.extractSessionId(request.url);
+      Logger.debug(TAG, `收到请求 method=${request.method} url=${request.url} range=${this.getHeader(request.headers, 'range') ?? ''}`);
+      if (!sessionId) {
+        return this.buildPlainResponse(404, 'not found');
+      }
+      const session = this.sessions.get(sessionId);
+      if (!session) {
+        return this.buildPlainResponse(404, 'session expired');
+      }
+      session.lastAccess = Date.now();
+      const method = (request.method || 'GET').toUpperCase();
+      if (method !== 'GET' && method !== 'HEAD') {
+        return this.buildPlainResponse(405, 'method not allowed');
+      }
+      if (method === 'HEAD') {
+        return this.buildHeadResponse(session);
+      }
+
+      const rangeHeader = this.getHeader(request.headers, 'range');
+      const rangeInfo = this.parseRangeHeader(rangeHeader);
+      let chunk: ChunkResult;
+      try {
+        chunk = await this.readChunk(session, rangeInfo.start, rangeInfo.end);
+      } catch (error) {
+        const err = error as Error;
+        if (err instanceof RangeNotSatisfiableError) {
+          return this.buildRangeNotSatisfiableResponse(err.availableRange);
+        }
+        throw err;
+      }
+      return this.buildChunkResponse(session, chunk, rangeInfo.isRange);
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `处理SMB流请求失败: ${err.message}`);
+      return this.buildPlainResponse(500, err.message || 'internal error');
+    }
+  }
+
+  private buildPlainResponse(statusCode: number, message: string): HttpResponse {
+    return {
+      statusCode,
+      result: message,
+      headers: {
+        'Content-Type': 'text/plain; charset=utf-8'
+      }
+    } as HttpResponse;
+  }
+
+  private buildRangeNotSatisfiableResponse(availableRange: string): HttpResponse {
+    return {
+      statusCode: 416,
+      result: 'Requested Range Not Satisfiable',
+      headers: {
+        'Content-Range': availableRange,
+        'Content-Type': 'text/plain; charset=utf-8'
+      }
+    } as HttpResponse;
+  }
+
+  private buildHeadResponse(session: StreamingSession): HttpResponse {
+    const headers: Record<string, string> = {
+      'Accept-Ranges': 'bytes',
+      'Content-Type': session.mimeType || 'application/octet-stream'
+    };
+    if (session.expectedSize && session.expectedSize > 0) {
+      headers['Content-Length'] = session.expectedSize.toString();
+    }
+    return {
+      statusCode: 200,
+      headers,
+      result: ''
+    } as HttpResponse;
+  }
+
+  private buildChunkResponse(session: StreamingSession, chunk: ChunkResult, isRange: boolean): HttpResponse {
+    const headers: Record<string, string> = {
+      'Content-Type': session.mimeType || 'application/octet-stream',
+      'Accept-Ranges': 'bytes',
+      'Content-Length': (chunk.end - chunk.start + 1).toString()
+    };
+    if (chunk.total && chunk.total > 0) {
+      headers['Content-Range'] = `bytes ${chunk.start}-${chunk.end}/${chunk.total}`;
+    }
+    return {
+      statusCode: isRange ? 206 : 200,
+      headers,
+      result: chunk.buffer
+    } as HttpResponse;
+  }
+
+  private extractSessionId(url: string): string | undefined {
+    if (!url) {
+      return undefined;
+    }
+    if (url.startsWith('http://') || url.startsWith('https://')) {
+      const slashIndex = url.indexOf('/', url.indexOf('//') + 2);
+      url = slashIndex >= 0 ? url.substring(slashIndex) : '/';
+    }
+    if (!url.startsWith(STREAM_ROUTE_PREFIX)) {
+      return undefined;
+    }
+    let relative = url.substring(STREAM_ROUTE_PREFIX.length);
+    if (relative.startsWith('/')) {
+      relative = relative.substring(1);
+    }
+    const queryIndex = relative.indexOf('?');
+    if (queryIndex >= 0) {
+      relative = relative.substring(0, queryIndex);
+    }
+    if (!relative || relative.length === 0) {
+      return undefined;
+    }
+    try {
+      return decodeURIComponent(relative);
+    } catch (error) {
+      Logger.error(TAG, `sessionId解析失败: ${(error as Error).message}`);
+      return undefined;
+    }
+  }
+
+  private parseRangeHeader(header?: string): RangeInfo {
+    if (!header || header.length === 0) {
+      return { start: 0, isRange: false };
+    }
+    const match = header.match(/bytes=([0-9]*)-([0-9]*)/i);
+    if (!match) {
+      return { start: 0, isRange: false };
+    }
+    let start = match[1] ? parseInt(match[1]) : 0;
+    const hasEnd = match[2] && match[2].length > 0;
+    let end = hasEnd ? parseInt(match[2]) : undefined;
+    if (isNaN(start) || start < 0) {
+      start = 0;
+    }
+    if (end === undefined || isNaN(end) || end < start) {
+      end = start + MAX_CHUNK_SIZE - 1;
+    }
+    const prefixRange = match[1] === undefined || match[1].length === 0;
+    if (prefixRange) {
+      const suffixBytes = parseInt(match[2]);
+      if (!isNaN(suffixBytes) && suffixBytes > 0) {
+        start = Math.max(0, end - suffixBytes + 1);
+      }
+    }
+    if (end !== undefined && (isNaN(end) || end < start)) {
+      end = start;
+    }
+    return { start, end, isRange: true };
+  }
+
+  private async readChunk(session: StreamingSession, requestedStart: number, requestedEnd?: number): Promise<ChunkResult> {
+    const start = requestedStart >= 0 ? requestedStart : 0;
+    let end = requestedEnd !== undefined ? requestedEnd : start + MAX_CHUNK_SIZE - 1;
+    if (end - start + 1 > MAX_CHUNK_SIZE) {
+      end = start + MAX_CHUNK_SIZE - 1;
+    }
+
+    const isFtp = session.protocol === 'ftp';
+    const timeout = isFtp ? RANGE_WAIT_TIMEOUT_MS * 3 : RANGE_WAIT_TIMEOUT_MS;
+    const pollInterval = isFtp ? RANGE_POLL_INTERVAL_MS * 2 : RANGE_POLL_INTERVAL_MS;
+
+    const waitStart = Date.now();
+    while (Date.now() - waitStart <= timeout) {
+      const size = await this.tryGetFileSize(session.cachePath);
+      Logger.debug(TAG, `range waiting start=${start} end=${end} currentSize=${size} cachePath=${session.cachePath} protocol=${session.protocol || 'unknown'}`);
+
+      if (size >= 0 && size > start) {
+        let fileEnd = Math.min(end, size - 1);
+        if (session.expectedSize && session.expectedSize > 0) {
+          fileEnd = Math.min(fileEnd, session.expectedSize - 1);
+        }
+        if (fileEnd >= start) {
+          const length = fileEnd - start + 1;
+          const buffer = new ArrayBuffer(length);
+          const file = fileIo.openSync(session.cachePath, fileIo.OpenMode.READ_ONLY);
+          try {
+            fileIo.readSync(file.fd, buffer, { offset: start, length });
+          } finally {
+            fileIo.closeSync(file);
+          }
+          const total = session.expectedSize && session.expectedSize > 0 ? session.expectedSize : Math.max(size, fileEnd + 1);
+          Logger.info(TAG, `从本地缓存返回范围 ${start}-${fileEnd},大小: ${length} bytes`);
+          return {
+            buffer,
+            start,
+            end: fileEnd,
+            total
+          };
+        }
+      }
+
+      const hasLocalData = size > 0;
+      const requestExceedsLocal = start >= size;
+
+      if (requestExceedsLocal) {
+        if (session.protocol === 'ftp' && !hasLocalData) {
+          Logger.debug(TAG, 'FTP本地无数据,直接尝试远程读取');
+        }
+        const remoteChunk = await this.fetchRemoteChunk(session, start, end, size);
+        if (remoteChunk && remoteChunk.buffer.byteLength > 0) {
+          Logger.info(TAG, `远程读取成功,返回 ${remoteChunk.buffer.byteLength} bytes,实现边下边播`);
+          return remoteChunk;
+        }
+
+        if (hasLocalData) {
+          Logger.info(TAG, `用户请求未下载区域 ${start}-${end},已下载到 ${size - 1},返回HTTP 416让播放器跳回`);
+          throw new RangeNotSatisfiableError(`bytes 0-${size - 1}/${session.expectedSize || '*'}`);
+        }
+      }
+      if (session.expectedSize && session.expectedSize > 0 && start >= session.expectedSize) {
+        throw new Error('请求范围超出文件大小');
+      }
+      await this.delay(pollInterval);
+    }
+    Logger.error(TAG, `range等待超时 start=${start} end=${end} cache=${session.cachePath} protocol=${session.protocol || 'unknown'}`);
+    throw new Error(`等待${session.protocol === 'ftp' ? 'FTP' : 'SMB'}缓存数据超时`);
+  }
+
+  private async tryGetFileSize(path: string): Promise<number> {
+    try {
+      const stat = await fileIo.stat(path);
+      return stat.size;
+    } catch (error) {
+      return -1;
+    }
+  }
+
+  private getHeader(headers: Record<string, string> | undefined, name: string): string | undefined {
+    if (!headers) {
+      return undefined;
+    }
+    const target = name.toLowerCase();
+    const keys = Object.keys(headers);
+    for (let i = 0; i < keys.length; i++) {
+      const key = keys[i];
+      if (key.toLowerCase() === target) {
+        return headers[key];
+      }
+    }
+    return undefined;
+  }
+
+  private cleanupExpiredSessions(): void {
+    const now = Date.now();
+    const keys = Array.from(this.sessions.keys());
+    for (let i = 0; i < keys.length; i++) {
+      const key = keys[i];
+      const session = this.sessions.get(key);
+      if (session && now - session.lastAccess > SESSION_TTL_MS) {
+        this.sessions.delete(key);
+        Logger.info(TAG, `移除过期SMB流会话: ${key}`);
+      }
+    }
+  }
+
+  private delay(ms: number): Promise<void> {
+    return new Promise(resolve => setTimeout(resolve, ms));
+  }
+
+  private async fetchRemoteChunk(
+    session: StreamingSession,
+    start: number,
+    desiredEnd: number,
+    currentLocalSize: number
+  ): Promise<ChunkResult | undefined> {
+    if (!session.protocol || !session.remotePath) {
+      return undefined;
+    }
+    const length = desiredEnd - start + 1;
+    if (length <= 0) {
+      return undefined;
+    }
+    try {
+      let data: ArrayBuffer | undefined;
+      if (session.protocol === 'smb' && session.smbConnection) {
+        data = readSmbRange(session.smbConnection, session.remotePath, start, length);
+      } else if (session.protocol === 'ftp' && session.ftpConnection) {
+        data = await readFtpRange(session.ftpConnection, session.remotePath, start, length);
+      }
+      if (!data || data.byteLength === 0) {
+        return undefined;
+      }
+      const shouldWriteToCache = currentLocalSize >= 0 && start <= currentLocalSize;
+      if (shouldWriteToCache) {
+        await this.writeRangeToCache(session.cachePath, start, data);
+      } else {
+        Logger.debug(TAG, `跳过写入缓存,避免产生空洞: start=${start}, currentLocalSize=${currentLocalSize}`);
+      }
+      const chunkEnd = start + data.byteLength - 1;
+      const totalSize = session.expectedSize && session.expectedSize > 0 ? session.expectedSize : Math.max(chunkEnd + 1, await this.tryGetFileSize(session.cachePath));
+      return {
+        buffer: data,
+        start,
+        end: chunkEnd,
+        total: totalSize
+      };
+    } catch (error) {
+      Logger.error(TAG, `远程读取失败: ${(error as Error).message}`);
+      return undefined;
+    }
+  }
+
+  private async writeRangeToCache(path: string, offset: number, data: ArrayBuffer): Promise<void> {
+    const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
+    try {
+      fileIo.writeSync(file.fd, data, { offset, length: data.byteLength });
+    } finally {
+      fileIo.closeSync(file);
+    }
+  }
+}

+ 54 - 16
entry/src/main/ets/view/LocalMusic.ets

@@ -83,10 +83,11 @@ import { resourceManager } from '@kit.LocalizationKit';
 import { deviceInfo } from '@kit.BasicServicesKit';
 import { RemoteCacheManager } from '../common/network/RemoteCacheManager';
 import '../common/network/RemoteCacheRegistry';
-import { RemoteCacheType } from '../common/network/RemoteSongCache';
+import { RemoteCacheType, resolveCacheFilePath } from '../common/network/RemoteSongCache';
 import { findWebDavCacheIfExists, triggerWebDavCacheDownload } from '../common/network/WebDavFileCache';
 import { navidromeApi } from '../common/network/NavidromeApi';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
+import { ensureSmbFileStreaming, SmbStreamingMeta } from '../common/network/SmbFileCache';
 
 import { HSBColorPicker, HSBColorPickerLayout } from '@keke/color-picker'
 import app from '@system.app';
@@ -341,12 +342,16 @@ interface WebDavMetadataUpdatePayload {
 }
 
 const metadataExtractionInFlight: Set<string> = new Set();
+const METADATA_RETRY_DELAY_MS = 1500;
+const METADATA_MAX_RETRY = 120;
 
-function scheduleMetadataExtractionFromCache(
+async function scheduleMetadataExtractionFromCache(
   song: VideoItem,
   cachePath: string,
-  options?: MetadataExtractionOptions
-): void {
+  options?: MetadataExtractionOptions,
+  expectedSize?: number,
+  retryCount: number = 0
+): Promise<void> {
   if (!song || !cachePath || !song.filePath || !options || !options.context) {
     return;
   }
@@ -359,6 +364,31 @@ function scheduleMetadataExtractionFromCache(
   if (song.bit_rate && song.sampleRate && song.duration) {
     return;
   }
+  const requiredSize = expectedSize && expectedSize > 0 ? expectedSize : undefined;
+  try {
+    const stat = await fileIo.stat(cachePath);
+    const ready = requiredSize !== undefined ? stat.size >= requiredSize : stat.size > 0;
+    if (!ready) {
+      if (retryCount >= METADATA_MAX_RETRY) {
+        Logger.warn(TAG, `缓存尚未完成,放弃解析: ${cachePath}`);
+        return;
+      }
+      setTimeout(() => {
+        void scheduleMetadataExtractionFromCache(song, cachePath, options, expectedSize, retryCount + 1);
+      }, METADATA_RETRY_DELAY_MS);
+      return;
+    }
+  } catch (error) {
+    if (retryCount >= METADATA_MAX_RETRY) {
+      Logger.warn(TAG, `无法读取缓存文件,放弃解析: ${(error as Error).message}`);
+      return;
+    }
+    setTimeout(() => {
+      void scheduleMetadataExtractionFromCache(song, cachePath, options, expectedSize, retryCount + 1);
+    }, METADATA_RETRY_DELAY_MS);
+    return;
+  }
+
   metadataExtractionInFlight.add(song.filePath);
   workerInstance.postMessage({
     code: 5,
@@ -366,7 +396,7 @@ function scheduleMetadataExtractionFromCache(
     data2: cachePath,
     data3: song.type,
     data4: {
-      name:song.name,
+      name: song.name,
       originFilePath: song.filePath,
       originId: song.id,
       autoParseMusicName: options.autoParseMusicName ?? false
@@ -467,7 +497,7 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
         const cachePath = await findWebDavCacheIfExists(account, relativePath);
         if (cachePath) {
           Logger.info(TAG, `WebDAV 缓存命中,直接播放: ${cachePath}`);
-          scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions);
+        void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
           return cachePath;
         }
 
@@ -478,7 +508,7 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
           if (downloadPromise) {
             downloadPromise.then((completedPath?: string | null) => {
               if (completedPath) {
-                scheduleMetadataExtractionFromCache(song, completedPath, metadataOptions);
+                void scheduleMetadataExtractionFromCache(song, completedPath, metadataOptions, song.videoSize);
               }
             }).catch((error: Error) => {
               Logger.error(TAG, `WebDAV 缓存后台下载失败: ${error.message}`);
@@ -531,16 +561,24 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
         throw new Error('SMB账号不可用');
       }
       const relativePath = extractSmbRelativePath(song, account.smbShare);
-      const cachedPath = await RemoteCacheManager.ensureCached(RemoteCacheType.SMB, {
-        account,
-        remotePath: relativePath
-      });
-      Logger.info(TAG, `SMB 缓存路径: ${cachedPath}`);
-      scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions);
-      return cachedPath;
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.SMB,
+        account.id?.toString(),
+        relativePath
+      );
+      const streamMeta: SmbStreamingMeta = {
+        fileSize: song.videoSize,
+        mimeType: song.mimeType,
+        fileName: song.fileName || song.name
+      };
+      const streamingUrl = await ensureSmbFileStreaming(account, pathInfo.normalizedRelative, streamMeta);
+      Logger.info(TAG, `SMB 流地址: ${streamingUrl}`);
+      void scheduleMetadataExtractionFromCache(song, pathInfo.cachePath, metadataOptions, song.videoSize);
+      const shouldBypassSanitize = streamingUrl.startsWith('http://127.0.0.1:');
+      return shouldBypassSanitize ? streamingUrl : sanitizePlaybackUrl(streamingUrl);
     } catch (error) {
       const err = error as Error;
-      Logger.error(TAG, `SMB 缓存失败: ${err.message}`);
+      Logger.error(TAG, `SMB 流式播放失败: ${err.message}`);
       throw new Error(err.message);
     }
   }
@@ -558,7 +596,7 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
         remotePath: relativePath
       });
       Logger.info(TAG, `FTP 缓存路径: ${cachedPath}`);
-      scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions);
+      void scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions, song.videoSize);
       return cachedPath;
     } catch (error) {
       const err = error as Error;