Sfoglia il codice sorgente

feat(remote): 新增FTP协议支持

- 在CommonConstants中添加TYPE_FTP类型定义
- 扩展RemoteDriveType枚举以支持Ftp类型
- 更新RemoteSongCache缓存类型并支持FTP清理
- 修改RemoteDriveLabel以返回FTP标签
- 引入FTP客户端库并在RemoteDriveManager中实现FTP文件加载逻辑
- 数据库表结构更新,增加ftpEncoding字段用于存储FTP编码设置
- 在RemoteDriveAccountDialog中新增FTP配置选项及连接解析功能
- LocalMusic页面集成FTP缓存机制与播放路径处理
- 新增FtpFileCache模块专门处理FTP文件下载与缓存
- 页面菜单中添加FTP新建入口并完善相关UI交互逻辑
chendeben 9 mesi fa
parent
commit
0242b20d07

+ 1 - 0
entry/src/main/ets/common/constants/CommonConstants.ets

@@ -209,6 +209,7 @@ export class CommonConstants {
   static readonly TYPE_WEBDAV: number = 3;//webdav文件
   static readonly TYPE_SMB: number = 4;//SMB文件
   static readonly TYPE_NAVIDROME: number = 5;//Navidrome流媒体文件
+  static readonly TYPE_FTP: number = 6;//FTP文件
 
   static readonly TYPE_LOCK: number = 200;//私密视频
   static readonly TYPE_LIKE: number = 201;//我收藏的视频

+ 2 - 1
entry/src/main/ets/common/enums/RemoteDriveType.ets

@@ -1,5 +1,6 @@
 export enum RemoteDriveType {
   WebDav = 0,
   Smb = 1,
-  Navidrome = 2
+  Navidrome = 2,
+  Ftp = 3
 }

+ 83 - 0
entry/src/main/ets/common/network/FtpFileCache.ets

@@ -0,0 +1,83 @@
+import { FtpClient, AccessOptions, StringEncoding } from '@liuzhosoft/ftp4h';
+import FileManager from '../util/FileManager';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
+import { fileIo } from '@kit.CoreFileKit';
+
+const TAG = 'FtpFileCache';
+
+function resolveFtpHost(account: WebDavAccount): string {
+  const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+  return host?.trim() ?? '';
+}
+
+function resolveFtpPort(account: WebDavAccount): number {
+  return account.port && account.port > 0 ? account.port : 21;
+}
+
+function resolveFtpEncoding(account: WebDavAccount): StringEncoding {
+  const value = account.ftpEncoding && account.ftpEncoding.length > 0 ? account.ftpEncoding : 'utf-8';
+  return value as StringEncoding;
+}
+
+async function downloadFtpFile(account: WebDavAccount, remotePath: string, localPath: string): Promise<void> {
+  const host = resolveFtpHost(account);
+  if (!host) {
+    throw new Error('FTP账户缺少服务器地址');
+  }
+
+  const client = new FtpClient();
+  const options: AccessOptions = {
+    host,
+    port: resolveFtpPort(account),
+    user: account.account && account.account.length > 0 ? account.account : undefined,
+    password: account.password && account.password.length > 0 ? account.password : undefined,
+    encoding: resolveFtpEncoding(account)
+  };
+  await client.access(options);
+
+  const normalizedPath = normalizeCacheRelativePath(remotePath);
+  if (normalizedPath === '/' || normalizedPath.length === 0) {
+    await client.close();
+    throw new Error('FTP远程路径无效');
+  }
+  const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+  try {
+    await client.read(normalizedPath, (data: ArrayBuffer) => {
+      fileIo.writeSync(file.fd, data);
+    });
+  } finally {
+    fileIo.closeSync(file);
+    await client.close();
+  }
+}
+
+export async function ensureFtpFileCached(account: WebDavAccount, remotePath: string): Promise<string> {
+  const normalizedRelative = normalizeCacheRelativePath(remotePath);
+  const cacheInfo = await resolveCacheFilePath(
+    RemoteCacheType.FTP,
+    account.id?.toString(),
+    normalizedRelative
+  );
+  const cachePath = cacheInfo.cachePath;
+  let exists = await FileManager.isExist(cachePath);
+  if (exists) {
+    const size = await FileManager.getFileSize(cachePath);
+    if (size <= 0) {
+      await FileManager.deleteFile(cachePath);
+      exists = false;
+    }
+  }
+  if (!exists) {
+    try {
+      await downloadFtpFile(account, normalizedRelative, cachePath);
+    } catch (error) {
+      await FileManager.deleteFile(cachePath);
+      const err = error as Error;
+      Logger.error(TAG, `FTP下载失败: ${err.message}`);
+      throw err;
+    }
+  }
+  return cachePath;
+}

+ 3 - 1
entry/src/main/ets/common/network/RemoteSongCache.ets

@@ -11,7 +11,8 @@ const CACHE_ROOT_DIR = 'remote_cache';
 
 export enum RemoteCacheType {
   WEBDAV = 'webdav',
-  SMB = 'smb'
+  SMB = 'smb',
+  FTP = 'ftp'
 }
 
 export interface CachePathInfo {
@@ -154,6 +155,7 @@ export async function clearRemoteCacheByAccount(
 export async function clearAllRemoteCaches(): Promise<void> {
   await clearRemoteCacheByAccount(RemoteCacheType.WEBDAV);
   await clearRemoteCacheByAccount(RemoteCacheType.SMB);
+  await clearRemoteCacheByAccount(RemoteCacheType.FTP);
 }
 
 export async function clearWebDavCacheByAccount(accountId?: string | number): Promise<void> {

+ 2 - 0
entry/src/main/ets/common/util/RemoteDriveLabel.ets

@@ -16,6 +16,8 @@ export function getRemoteDriveProtocolLabel(type?: number): string {
       return 'SMB';
     case RemoteDriveType.Navidrome:
       return 'Navidrome';
+    case RemoteDriveType.Ftp:
+      return 'FTP';
     case RemoteDriveType.WebDav:
     default:
       return 'WebDAV';

+ 146 - 13
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -20,6 +20,7 @@ import { listSmbDirectory, SmbDirectoryEntry } from '../network/SmbBridge';
 import { NavidromeApi, NavidromeAlbumDetail, NavidromeArtist, NavidromeArtistDetail, NavidromeSong } from '../network/NavidromeApi';
 import { WebDavUrlUtil } from './WebDavUrlUtil';
 import MediaTable from './MediaTable';
+import { FtpClient, FileInfo as FtpEntryInfo, StringEncoding, AccessOptions } from '@liuzhosoft/ftp4h';
 
 const TAG = 'heanup RemoteDriveManager';
 
@@ -177,6 +178,7 @@ export class RemoteDriveManager {
         const smbShareIndex = resultSet.getColumnIndex('smbShare');
         const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
         const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
+        const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
         if (smbShareIndex >= 0) {
           account.smbShare = resultSet.getString(smbShareIndex) ?? '';
         }
@@ -319,7 +321,7 @@ export class RemoteDriveManager {
       // 查询所有TYPE_WEBDAV且webdav_account_id为空或null的记录
       const querySql = `
         SELECT id, filePath FROM mediaTable
-        WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB})
+        WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP})
         AND (webdav_account_id IS NULL OR webdav_account_id = '')
       `;
 
@@ -333,7 +335,7 @@ export class RemoteDriveManager {
         const updateSql = `
           UPDATE mediaTable
           SET webdav_account_id = ?
-          WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB})
+          WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP})
           AND (webdav_account_id IS NULL OR webdav_account_id = '')
         `;
 
@@ -408,12 +410,26 @@ export class RemoteDriveManager {
         account.webType = resultSet.getLong(resultSet.getColumnIndex('webType'));
         const smbShareIndex = resultSet.getColumnIndex('smbShare');
         const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
+        const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
         if (smbShareIndex >= 0) {
           account.smbShare = resultSet.getString(smbShareIndex) ?? '';
         }
         if (smbDomainIndex >= 0) {
           account.smbDomain = resultSet.getString(smbDomainIndex) ?? '';
         }
+        const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
+        if (navBaseIndex >= 0) {
+          const stored = resultSet.getString(navBaseIndex);
+          account.navidromeBasePath = stored && stored.length > 0 ? stored : '/rest';
+        } else {
+          account.navidromeBasePath = '/rest';
+        }
+        if (ftpEncodingIndex >= 0) {
+          const encodingValue = resultSet.getString(ftpEncodingIndex);
+          account.ftpEncoding = encodingValue && encodingValue.length > 0 ? encodingValue : 'utf-8';
+        } else {
+          account.ftpEncoding = 'utf-8';
+        }
         account.isActivate = resultSet.getLong(resultSet.getColumnIndex('isActivate')) === 1;
 
         resultSet.close();
@@ -450,7 +466,8 @@ export class RemoteDriveManager {
       webType INTEGER DEFAULT 0,
       smbShare TEXT,
       smbDomain TEXT,
-      navidromeBasePath TEXT
+      navidromeBasePath TEXT,
+      ftpEncoding TEXT
     )`;
 
     return this.dataBaseUtil.executeSql(createTableSql)
@@ -477,7 +494,6 @@ export class RemoteDriveManager {
       Logger.info(TAG, 'coverPath字段添加成功,数据库升级完成');
     } catch (error) {
       // 如果添加字段失败(可能字段已存在),记录但不阻止应用启动
-      Logger.info(TAG, 'coverPath字段可能已存在或添加失败,继续正常运行');
     }
 
     try {
@@ -489,7 +505,6 @@ export class RemoteDriveManager {
       Logger.info(TAG, 'webType字段添加成功,数据库升级完成');
     } catch (error) {
       // 如果添加字段失败(可能字段已存在),记录但不阻止应用启动
-      Logger.info(TAG, 'webType字段可能已存在或添加失败,继续正常运行');
     }
 
     try {
@@ -498,7 +513,6 @@ export class RemoteDriveManager {
       await this.dataBaseUtil.executeSql(addSmbShareSql);
       Logger.info(TAG, 'smbShare字段添加成功');
     } catch (error) {
-      Logger.info(TAG, 'smbShare字段可能已存在或添加失败,继续运行');
     }
 
     try {
@@ -507,7 +521,6 @@ export class RemoteDriveManager {
       await this.dataBaseUtil.executeSql(addSmbDomainSql);
       Logger.info(TAG, 'smbDomain字段添加成功');
     } catch (error) {
-      Logger.info(TAG, 'smbDomain字段可能已存在或添加失败,继续运行');
     }
 
     try {
@@ -516,7 +529,14 @@ export class RemoteDriveManager {
       await this.dataBaseUtil.executeSql(addNavBaseSql);
       Logger.info(TAG, 'navidromeBasePath字段添加成功');
     } catch (error) {
-      Logger.info(TAG, 'navidromeBasePath字段可能已存在或添加失败,继续运行');
+    }
+
+    try {
+      Logger.info(TAG, '尝试添加ftpEncoding字段...');
+      const addFtpEncodingSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN ftpEncoding TEXT`;
+      await this.dataBaseUtil.executeSql(addFtpEncodingSql);
+      Logger.info(TAG, 'ftpEncoding字段添加成功');
+    } catch (error) {
     }
   }
 
@@ -530,7 +550,7 @@ export class RemoteDriveManager {
       // 先查询基础字段(确保这些字段在旧版本中存在)
       const baseColumns = ['id', 'name', 'isActivate', 'host', 'localHost', 'isUseLocalHost',
         'port', 'filepath', 'imageFilePath', 'lyricFilePath', 'uploadFilePath',
-        'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain', 'navidromeBasePath'];
+        'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain', 'navidromeBasePath', 'ftpEncoding'];
 
       const resultSet = await this.dataBaseUtil.queryData(this.webDavTable, baseColumns, predicates);
 
@@ -573,6 +593,13 @@ export class RemoteDriveManager {
         } else {
           account.navidromeBasePath = '/rest';
         }
+        const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
+        if (ftpEncodingIndex >= 0) {
+          const encodingValue = resultSet.getString(ftpEncodingIndex);
+          account.ftpEncoding = encodingValue && encodingValue.length > 0 ? encodingValue : 'utf-8';
+        } else {
+          account.ftpEncoding = 'utf-8';
+        }
 
         this.webDavAccounts.push(account);
       }
@@ -606,7 +633,8 @@ export class RemoteDriveManager {
     webType: number = 0,
     smbShare: string = '',
     smbDomain: string = '',
-    navidromeBasePath: string = '/rest'
+    navidromeBasePath: string = '/rest',
+    ftpEncoding: string = 'utf-8'
   ): Promise<void> {
     try {
       const values: relationalStore.ValuesBucket = {
@@ -627,7 +655,8 @@ export class RemoteDriveManager {
         'webType': webType,
         'smbShare': smbShare,
         'smbDomain': smbDomain,
-        'navidromeBasePath': navidromeBasePath
+        'navidromeBasePath': navidromeBasePath,
+        'ftpEncoding': ftpEncoding
       };
 
       await this.dataBaseUtil.insertData(this.webDavTable, values);
@@ -664,7 +693,8 @@ export class RemoteDriveManager {
         'webType': account.webType,
         'smbShare': account.smbShare,
         'smbDomain': account.smbDomain,
-        'navidromeBasePath': account.navidromeBasePath
+        'navidromeBasePath': account.navidromeBasePath,
+        'ftpEncoding': account.ftpEncoding
       };
 
       const predicates = new relationalStore.RdbPredicates(this.webDavTable);
@@ -759,6 +789,8 @@ export class RemoteDriveManager {
         await this.loadSmbFiles(account, normalizedFullPath);
       } else if (account.webType === RemoteDriveType.Navidrome) {
         await this.loadNavidromeFiles(account, normalizedFullPath);
+      } else if (account.webType === RemoteDriveType.Ftp) {
+        await this.loadFtpFiles(account, normalizedFullPath);
       } else {
         await this.loadWebDavFiles(account, normalizedFullPath);
       }
@@ -867,6 +899,58 @@ export class RemoteDriveManager {
     Logger.info(TAG, `从SMB获取到 ${entries.length} 个文件/文件夹`);
   }
 
+  private resolveFtpHost(account: WebDavAccount): string {
+    const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+    return host?.trim() ?? '';
+  }
+
+  private getFtpEncoding(account: WebDavAccount): StringEncoding {
+    if (account.ftpEncoding && account.ftpEncoding.length > 0) {
+      return account.ftpEncoding as StringEncoding;
+    }
+    return 'utf-8';
+  }
+
+  private buildFtpAccessOptions(account: WebDavAccount): AccessOptions {
+    const host = this.resolveFtpHost(account);
+    if (!host) {
+      throw new Error('FTP账户缺少服务器地址');
+    }
+    return {
+      host,
+      port: account.port && account.port > 0 ? account.port : 21,
+      user: account.account && account.account.length > 0 ? account.account : undefined,
+      password: account.password && account.password.length > 0 ? account.password : undefined,
+      encoding: this.getFtpEncoding(account)
+    };
+  }
+
+  private async loadFtpFiles(account: WebDavAccount, fullPath: string): Promise<void> {
+    const client = new FtpClient();
+    const options = this.buildFtpAccessOptions(account);
+    await client.access(options);
+    try {
+      const listPath = this.normalizeFullPath(fullPath);
+      const entries = await client.list(listPath === '/' ? '/' : listPath);
+      this.webDavFiles = [];
+      this.webDavSongs = [];
+      for (let i = 0; i < entries.length; i++) {
+        const entry = entries[i];
+        const info = this.ftpEntryToFileInfo(entry, listPath);
+        this.webDavFiles.push(info);
+        if (info.isDirectory) {
+          this.registerPathLabel(info.href, info.fileName);
+        } else if (this.isAudioFile(info.fileName)) {
+          this.webDavSongs.push(this.buildFtpVideoItem(info, account));
+        }
+      }
+      await this.enrichSongsWithDatabase(this.webDavSongs);
+      Logger.info(TAG, `从FTP获取到 ${entries.length} 个文件/文件夹`);
+    } finally {
+      await client.close();
+    }
+  }
+
   private async loadNavidromeFiles(account: WebDavAccount, fullPath: string): Promise<void> {
     const normalized = this.normalizeFullPath(fullPath);
     this.registerPathLabel('/', '根目录');
@@ -968,7 +1052,7 @@ export class RemoteDriveManager {
       }
       return null;
     }
-    if (item.type === CommonConstants.TYPE_SMB || item.type === CommonConstants.TYPE_NAVIDROME) {
+    if (item.type === CommonConstants.TYPE_SMB || item.type === CommonConstants.TYPE_NAVIDROME || item.type === CommonConstants.TYPE_FTP) {
       if (item.remote_rel_path) {
         return item.remote_rel_path;
       }
@@ -1004,6 +1088,9 @@ export class RemoteDriveManager {
     if (account.webType === RemoteDriveType.Smb) {
       return this.buildSmbVideoItem(fileInfo, account);
     }
+    if (account.webType === RemoteDriveType.Ftp) {
+      return this.buildFtpVideoItem(fileInfo, account);
+    }
     // 构建安全的WebDAV URL(不包含认证信息)
     const protocol = account.enableHttps ? 'https' : 'http';
     const host = account.isUseLocalHost ? account.localHost : account.host;
@@ -1101,6 +1188,52 @@ export class RemoteDriveManager {
     return videoItem;
   }
 
+  private ftpEntryToFileInfo(entry: FtpEntryInfo, basePath: string): FileInfo {
+    const normalizedBase = this.normalizeFullPath(basePath);
+    const targetPath = this.normalizeFullPath(`${normalizedBase === '/' ? '' : normalizedBase}/${entry.name}`);
+    const info = new FileInfo(normalizedBase, entry.name, entry.size ?? 0, entry.modifiedAt ? entry.modifiedAt.getTime() : Date.now());
+    info.fileName = entry.name;
+    info.href = targetPath;
+    info.isDirectory = Boolean(entry.isDirectory);
+    info.contentLength = entry.size ?? 0;
+    info.time = entry.modifiedAt ? entry.modifiedAt.getTime() : Date.now();
+    return info;
+  }
+
+  private buildFtpVideoItem(fileInfo: FileInfo, account: WebDavAccount): VideoItem {
+    const host = this.resolveFtpHost(account);
+    if (!host) {
+      throw new Error('FTP账号缺少服务器地址');
+    }
+    const normalizedPath = this.normalizeFullPath(fileInfo.href || `/${fileInfo.fileName}`);
+    const encodedPath = encodeURI(normalizedPath);
+    const port = account.port && account.port > 0 ? `:${account.port}` : '';
+    const ftpUrl = `ftp://${host}${port}${encodedPath}`;
+
+    const videoItem = new VideoItem(
+      this.getFileNameWithoutExtension(fileInfo.fileName),
+      '',
+      ftpUrl,
+      CommonConstants.TYPE_FTP,
+      fileInfo.contentLength,
+      Utility.getFormatDateStr(fileInfo.time, 'yyyy-MM-dd HH:mm')
+    );
+    videoItem.album = '';
+    videoItem.size = Utility.formatFSize(fileInfo.contentLength);
+    const musicData: MusicInfo = parseMusicFileName(fileInfo.fileName);
+    if (musicData.isValid) {
+      videoItem.artist = musicData.artist;
+      videoItem.name = musicData.title;
+    } else {
+      videoItem.artist = Constants.UNKNOWN_ARTIST;
+    }
+    if (account && account.id) {
+      videoItem.webdav_account_id = account.id.toString();
+    }
+    videoItem.remote_rel_path = normalizedPath;
+    return videoItem;
+  }
+
   private smbEntryToFileInfo(entry: SmbDirectoryEntry, basePath: string): FileInfo {
     const normalizedPath = this.combineRemotePath(basePath, entry.name, entry.isDirectory);
     const info = new FileInfo('', entry.name, entry.size, Date.now());

+ 58 - 3
entry/src/main/ets/dialog/RemoteDriveAccountDialog.ets

@@ -48,6 +48,7 @@ export struct RemoteDriveAccountDialog {
   @State navidromeBasePath: string = '/rest';
   @State coverPath: string = '';
   @State isDarkMode: boolean = false;
+  @State ftpEncoding: string = 'utf-8';
   @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
   @StorageProp('currentColorMode') @Watch('onColorModeChange') currentMode: number =
     ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT;
@@ -77,6 +78,7 @@ export struct RemoteDriveAccountDialog {
       this.shareName = this.account.smbShare ?? '';
       this.domain = this.account.smbDomain ?? '';
       this.coverPath = this.account.coverPath || '';
+      this.ftpEncoding = this.account.ftpEncoding ?? 'utf-8';
       this.nameCustomized = true;
       if (this.driveType === RemoteDriveType.Navidrome) {
         this.navidromeBasePath = this.normalizeNavidromeBasePath(this.account.navidromeBasePath ?? '/rest');
@@ -277,6 +279,22 @@ export struct RemoteDriveAccountDialog {
         this.buildHelperText('可选,用于需要域/工作组认证的 SMB 服务器')
       }
 
+      if (this.driveType === RemoteDriveType.Ftp) {
+        Row({ space: 8 }) {
+          Text('编码')
+            .fontSize(14)
+            .fontColor($r('app.color.index_tab_font_color'));
+          TextInput({ placeholder: '默认 utf-8,可填 gbk 等', text: this.ftpEncoding })
+            .layoutWeight(1)
+            .maxLines(1)
+            .onChange((value: string) => {
+              this.ftpEncoding = value.trim();
+            });
+        }
+        .alignItems(VerticalAlign.Center);
+        this.buildHelperText('若目录或文件名为中文,可根据服务器设置调整编码');
+      }
+
       if (this.driveType === RemoteDriveType.Navidrome) {
         Row({ space: 8 }) {
           Text('API路径')
@@ -398,6 +416,7 @@ export struct RemoteDriveAccountDialog {
             updatedAccount.smbShare = this.shareName;
             updatedAccount.smbDomain = this.domain;
             updatedAccount.navidromeBasePath = this.normalizeNavidromeBasePath(this.navidromeBasePath);
+            updatedAccount.ftpEncoding = this.ftpEncoding && this.ftpEncoding.length > 0 ? this.ftpEncoding : 'utf-8';
             this.onConfirm?.(updatedAccount);
 
           });
@@ -422,6 +441,7 @@ export struct RemoteDriveAccountDialog {
         this.buildTypeButton('WebDAV', RemoteDriveType.WebDav);
         this.buildTypeButton('SMB', RemoteDriveType.Smb);
         this.buildTypeButton('Navidrome', RemoteDriveType.Navidrome);
+        this.buildTypeButton('FTP', RemoteDriveType.Ftp);
       }
       .width('100%');
     }
@@ -443,7 +463,7 @@ export struct RemoteDriveAccountDialog {
     const changed = this.driveType !== type;
     if (changed) {
       this.driveType = type;
-      if (type === RemoteDriveType.Smb) {
+      if (type === RemoteDriveType.Smb || type === RemoteDriveType.Ftp) {
         this.enableHttps = false;
       }
     }
@@ -459,6 +479,9 @@ export struct RemoteDriveAccountDialog {
     if (type === RemoteDriveType.Navidrome && !this.navBasePathCustomized) {
       this.navidromeBasePath = '/rest';
     }
+    if (type === RemoteDriveType.Ftp && (!this.ftpEncoding || this.ftpEncoding.length === 0)) {
+      this.ftpEncoding = 'utf-8';
+    }
     this.updatePortForType(type, forcePort);
   }
 
@@ -475,6 +498,8 @@ export struct RemoteDriveAccountDialog {
         return '新建SMB';
       case RemoteDriveType.Navidrome:
         return '新建Navidrome';
+      case RemoteDriveType.Ftp:
+        return '新建FTP';
       default:
         return '新建WebDAV';
     }
@@ -490,6 +515,8 @@ export struct RemoteDriveAccountDialog {
         return '支持 smb://user:pass@host/share 输入,自动拆分账户、共享、路径';
       case RemoteDriveType.Navidrome:
         return '可粘贴 Navidrome/Subsonic 连接(如 https://user:pass@host:4533/rest),自动填充参数';
+      case RemoteDriveType.Ftp:
+        return '支持 ftp://user:pass@host:21/path 输入,自动填充账户和目录';
       default:
         return '支持 https://user:pass@host:port/path WebDAV 连接串,自动填充账户、端口和目录';
     }
@@ -506,6 +533,9 @@ export struct RemoteDriveAccountDialog {
     if (this.driveType === RemoteDriveType.Navidrome) {
       return this.enableHttps ? 443 : 4533;
     }
+    if (this.driveType === RemoteDriveType.Ftp) {
+      return 21;
+    }
     return this.enableHttps ? 443 : 5005;
   }
 
@@ -559,6 +589,14 @@ export struct RemoteDriveAccountDialog {
       ToastUtil.showToast('已解析 WebDAV 连接');
       return true;
     }
+    if (protocol === 'ftp' || protocol === 'ftps') {
+      if (this.driveType !== RemoteDriveType.Ftp) {
+        this.handleDriveTypeChange(RemoteDriveType.Ftp);
+      }
+      this.applyParsedFtp(parsed);
+      ToastUtil.showToast('已解析 FTP 连接');
+      return true;
+    }
     return false;
   }
 
@@ -617,6 +655,23 @@ export struct RemoteDriveAccountDialog {
     this.filepath = '/';
   }
 
+  private applyParsedFtp(parsed: ParsedConnectionParts): void {
+    this.enableHttps = false;
+    this.host = parsed.host;
+    if (parsed.port) {
+      this.updatePortState(parsed.port, true);
+    } else {
+      this.updatePortForType(RemoteDriveType.Ftp, true);
+    }
+    if (parsed.username) {
+      this.username = parsed.username;
+    }
+    if (parsed.password) {
+      this.password = parsed.password;
+    }
+    this.filepath = parsed.path && parsed.path.length > 0 ? parsed.path : '/';
+  }
+
   private guessNavidromePath(path?: string): boolean {
     if (!path) {
       return false;
@@ -685,8 +740,8 @@ export struct RemoteDriveAccountDialog {
         Logger.info('heanup RemoteDriveAccountDialog', `选择封面成功: ${selectedPath}`)
       }
     } catch (error) {
-        Logger.error('heanup RemoteDriveAccountDialog', `选择封面失败: ${(error as Error).message}`)
-        ToastUtil.showToast('选择封面失败')
+      Logger.error('heanup RemoteDriveAccountDialog', `选择封面失败: ${(error as Error).message}`)
+      ToastUtil.showToast('选择封面失败')
     }
   }
 

+ 9 - 1
entry/src/main/ets/pages/NewIndex.ets

@@ -1344,6 +1344,13 @@ struct NewIndex {
         .onClick(async () => {
           this.showRemoteDriveAccountDialog(false,undefined,RemoteDriveType.Smb)
         })
+      MenuItem({
+        symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.smallcircle_filled_circle')),
+        content: $r('app.string.ftp')
+      })
+        .onClick(async () => {
+          this.showRemoteDriveAccountDialog(false,undefined,RemoteDriveType.Ftp)
+        })
       MenuItem({
         symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.smallcircle_filled_circle')),
         content: $r('app.string.navidrome')
@@ -1706,7 +1713,8 @@ struct NewIndex {
             account.webType,
             account.smbShare,
             account.smbDomain,
-            account.navidromeBasePath
+            account.navidromeBasePath,
+            account.ftpEncoding
           ).then(() => {
              ToastUtil.showToast('添加成功')
              // 重新加载网盘账户列表 观察着模式去更新了,所以这里不需要更新,先注释掉

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

@@ -81,6 +81,7 @@ import { BreakpointType, BreakpointTypeEnum } from '../common/util/BreakpointSys
 import { resourceManager } from '@kit.LocalizationKit';
 import { deviceInfo } from '@kit.BasicServicesKit';
 import { ensureSmbFileCached } from '../common/network/SmbFileCache';
+import { ensureFtpFileCached } from '../common/network/FtpFileCache';
 import { findWebDavCacheIfExists, triggerWebDavCacheDownload } from '../common/network/WebDavFileCache';
 import { navidromeApi } from '../common/network/NavidromeApi';
 
@@ -173,8 +174,12 @@ function isNavidromeType(type: number): boolean {
   return type === CommonConstants.TYPE_NAVIDROME;
 }
 
+function isFtpType(type: number): boolean {
+  return type === CommonConstants.TYPE_FTP;
+}
+
 function isRemoteCloudType(type: number): boolean {
-  return isWebDavType(type) || isSmbType(type) || isNavidromeType(type);
+  return isWebDavType(type) || isSmbType(type) || isNavidromeType(type) || isFtpType(type);
 }
 
 function getShareNameFromFilePath(filePath?: string): string | undefined {
@@ -235,6 +240,20 @@ function extractSmbRelativePath(song: VideoItem, shareNameOverride?: string): st
   return remainder;
 }
 
+function extractFtpRelativePath(song: VideoItem): string {
+  if (song.remote_rel_path && song.remote_rel_path.length > 0) {
+    return song.remote_rel_path.replace(/^\/+/, '');
+  }
+  if (!song.filePath) {
+    return '';
+  }
+  const match = song.filePath.match(/^ftp:\/\/[^/]+(?::\d+)?(\/.*)$/i);
+  if (match && match[1]) {
+    return match[1].replace(/^\/+/, '');
+  }
+  return song.filePath.replace(/^\/+/, '');
+}
+
 const workerInstance = new worker.ThreadWorker("entry/ets/workers/Worker.ets");
 
 interface MetadataExtractionOptions {
@@ -513,6 +532,25 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
     }
   }
 
+  if (isFtpType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('FTP账号不可用');
+      }
+      const relativePath = extractFtpRelativePath(song);
+      const cachedPath = await ensureFtpFileCached(account, relativePath);
+      Logger.info(TAG, `FTP 缓存路径: ${cachedPath}`);
+      scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions);
+      return cachedPath;
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `FTP 缓存失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
   if (isWebDavType(song.type)) {
     Logger.warn(TAG, `WebDAV歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
     return sanitizePlaybackUrl(song.filePath);
@@ -521,6 +559,10 @@ async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionO
     Logger.warn(TAG, `SMB歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
     return song.filePath;
   }
+  if (isFtpType(song.type)) {
+    Logger.warn(TAG, `FTP歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
+    return song.filePath;
+  }
   if (isNavidromeType(song.type)) {
     throw new Error('Navidrome歌曲缺少webdav_account_id,无法构建播放链接');
   }
@@ -6740,6 +6782,7 @@ export struct LocalMusic {
       case CommonConstants.TYPE_WEBDAV:
       case CommonConstants.TYPE_SMB:
       case CommonConstants.TYPE_NAVIDROME:
+      case CommonConstants.TYPE_FTP:
         // 处理网络音频播放(WebDAV/SMB)
         Logger.info(`heanup 处理云端音频播放: ${item.name}, URL: ${item.filePath}`)
 

+ 1 - 0
entry/src/main/ets/viewmodel/WebDavAccount.ets

@@ -28,6 +28,7 @@ export class WebDavAccount{
   public smbShare: string = ''
   public smbDomain: string = ''
   public navidromeBasePath: string = '/rest'
+  public ftpEncoding: string = 'utf-8'
 
   public setIsUseLocalHost(isuse: boolean){
     this.isUseLocalHost = isuse