Kaynağa Gözat

feat(remote): 添加Navidrome流媒体支持- 新增Navidrome流媒体文件类型常量- 扩展RemoteDriveType枚举以支持Navidrome
- 在RemoteDriveManager中实现Navidrome文件加载逻辑
- 添加NavidromeApi类用于与Navidrome服务通信
- 更新数据库结构以存储Navidrome账户信息
- 修改RemoteDriveAccountDialog以支持Navidrome配置
- 在LocalMusic中添加Navidrome歌曲播放支持- 实现Navidrome路径标签显示和面包屑导航
- 添加Navidrome连接字符串解析功能- 更新WebDavAccount模型以包含Navidrome相关字段

chendeben 9 ay önce
ebeveyn
işleme
4bcbc855bf

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

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

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

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

+ 331 - 0
entry/src/main/ets/common/network/NavidromeApi.ets

@@ -0,0 +1,331 @@
+import { http } from '@kit.NetworkKit';
+import { MD5 } from '@pura/harmony-utils';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+
+const TAG = 'heanup NavidromeApi';
+const API_VERSION = '1.16.1';
+const CLIENT_NAME = 'TTMusic';
+
+export interface NavidromeArtist {
+  id: string;
+  name: string;
+  albumCount?: number;
+}
+
+export interface NavidromeArtistDetail extends NavidromeArtist {
+  albums: NavidromeAlbum[];
+}
+
+export interface NavidromeAlbum {
+  id: string;
+  name: string;
+  artist?: string;
+  coverArt?: string;
+  year?: number;
+  songCount?: number;
+}
+
+export interface NavidromeAlbumDetail extends NavidromeAlbum {
+  songs: NavidromeSong[];
+}
+
+export interface NavidromeSong {
+  id: string;
+  title: string;
+  artist?: string;
+  album?: string;
+  track?: number;
+  duration?: number;
+  bitRate?: number;
+  suffix?: string;
+  size?: number;
+  contentType?: string;
+  coverArt?: string;
+}
+
+interface SubsonicError {
+  code?: number;
+  message?: string;
+}
+
+interface SubsonicArtistEntry {
+  id?: string;
+  name?: string;
+  albumCount?: number;
+}
+
+interface SubsonicIndexEntry {
+  name?: string;
+  artist?: Array<SubsonicArtistEntry>;
+}
+
+interface SubsonicIndexes {
+  index?: Array<SubsonicIndexEntry>;
+}
+
+interface SubsonicAlbumEntry {
+  id?: string;
+  name?: string;
+  artist?: string;
+  coverArt?: string;
+  year?: number;
+  songCount?: number;
+  song?: Array<SubsonicSongEntry>;
+}
+
+interface SubsonicSongEntry {
+  id?: string;
+  title?: string;
+  artist?: string;
+  album?: string;
+  track?: number;
+  duration?: number;
+  bitRate?: number;
+  suffix?: string;
+  size?: number;
+  contentType?: string;
+  coverArt?: string;
+}
+
+interface SubsonicArtistBody {
+  id?: string;
+  name?: string;
+  albumCount?: number;
+  album?: Array<SubsonicAlbumEntry>;
+}
+
+interface SubsonicBody {
+  status: string;
+  error?: SubsonicError;
+  indexes?: SubsonicIndexes;
+  artist?: SubsonicArtistBody;
+  album?: SubsonicAlbumEntry;
+}
+
+interface SubsonicRoot {
+  ['subsonic-response']: SubsonicBody;
+}
+
+class QueryParam {
+  key: string;
+  value: string;
+
+  constructor(key: string, value: string) {
+    this.key = key;
+    this.value = value;
+  }
+}
+
+export class NavidromeApi {
+  async getArtists(account: WebDavAccount): Promise<NavidromeArtist[]> {
+    const response = await this.request(account, 'getIndexes', []);
+    const indexes = response.indexes;
+    const artists: NavidromeArtist[] = [];
+    if (indexes && indexes.index) {
+      for (let i = 0; i < indexes.index.length; i++) {
+        const entry = indexes.index[i];
+        const entryArtists = entry.artist;
+        if (!entryArtists) {
+          continue;
+        }
+        for (let j = 0; j < entryArtists.length; j++) {
+          const artist = entryArtists[j];
+          if (artist.id && artist.name) {
+            artists.push({
+              id: artist.id,
+              name: artist.name,
+              albumCount: artist.albumCount
+            });
+          }
+        }
+      }
+    }
+    return artists;
+  }
+
+  async getArtist(account: WebDavAccount, artistId: string): Promise<NavidromeArtistDetail> {
+    const response = await this.request(account, 'getArtist', [new QueryParam('id', artistId)]);
+    const artist = response.artist;
+    if (!artist || !artist.id || !artist.name) {
+      throw new Error('Navidrome 返回的艺术家数据无效');
+    }
+    const albums: NavidromeAlbum[] = [];
+    if (artist.album) {
+      for (let i = 0; i < artist.album.length; i++) {
+        const album = artist.album[i];
+        if (!album.id || !album.name) {
+          continue;
+        }
+        albums.push({
+          id: album.id,
+          name: album.name,
+          artist: album.artist ?? artist.name,
+          coverArt: album.coverArt,
+          year: album.year,
+          songCount: album.songCount
+        });
+      }
+    }
+    return {
+      id: artist.id,
+      name: artist.name,
+      albumCount: artist.albumCount,
+      albums
+    };
+  }
+
+  async getAlbum(account: WebDavAccount, albumId: string): Promise<NavidromeAlbumDetail> {
+    const response = await this.request(account, 'getAlbum', [new QueryParam('id', albumId)]);
+    const album = response.album;
+    if (!album || !album.id || !album.name) {
+      throw new Error('Navidrome 返回的专辑数据无效');
+    }
+    const songs: NavidromeSong[] = [];
+    if (album.song) {
+      for (let i = 0; i < album.song.length; i++) {
+        const song = album.song[i];
+        if (!song.id) {
+          continue;
+        }
+        songs.push({
+          id: song.id,
+          title: song.title ?? '未知曲目',
+          artist: song.artist,
+          album: song.album,
+          track: song.track,
+          duration: song.duration,
+          bitRate: song.bitRate,
+          suffix: song.suffix,
+          size: song.size,
+          contentType: song.contentType,
+          coverArt: song.coverArt
+        });
+      }
+    }
+    return {
+      id: album.id,
+      name: album.name,
+      artist: album.artist,
+      coverArt: album.coverArt,
+      year: album.year,
+      songCount: album.songCount,
+      songs
+    };
+  }
+
+  async buildStreamUrl(account: WebDavAccount, songId: string | undefined): Promise<string> {
+    if (!songId) {
+      throw new Error('Navidrome歌曲ID缺失');
+    }
+    const baseUrl = this.buildBaseUrl(account);
+    const params: QueryParam[] = [];
+    params.push(new QueryParam('id', songId));
+    this.appendParams(params, await this.buildAuthParams(account));
+    this.appendCommonParams(params);
+    const query = this.buildQueryString(params);
+    return `${baseUrl}/stream?${query}`;
+  }
+
+  private async request(account: WebDavAccount, endpoint: string, extraParams: Array<QueryParam>): Promise<SubsonicBody> {
+    const baseUrl = this.buildBaseUrl(account);
+    const params: QueryParam[] = [];
+    this.appendParams(params, await this.buildAuthParams(account));
+    this.appendParams(params, extraParams);
+    this.appendCommonParams(params);
+    const query = this.buildQueryString(params);
+    const url = `${baseUrl}/${endpoint}?${query}`;
+
+    const httpRequest = http.createHttp();
+    try {
+      const options: http.HttpRequestOptions = {
+        method: http.RequestMethod.GET,
+        connectTimeout: 10000,
+        readTimeout: 15000,
+        expectDataType: http.HttpDataType.STRING
+      };
+      const response = await httpRequest.request(url, options);
+      if (response.responseCode !== 200) {
+        throw new Error(`Navidrome 请求失败: HTTP ${response.responseCode}`);
+      }
+      const result = response.result as string;
+      const parsed = JSON.parse(result) as SubsonicRoot;
+      const body = Reflect.get(parsed as object, 'subsonic-response') as SubsonicBody | undefined;
+      if (!body) {
+        throw new Error('Navidrome 响应格式异常');
+      }
+      if (body.status !== 'ok') {
+        const errorInfo = body.error;
+        const message = errorInfo && errorInfo.message ? errorInfo.message : 'Navidrome 返回错误';
+        throw new Error(message);
+      }
+      return body;
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `Navidrome 请求失败: ${err.message}`);
+      throw err;
+    } finally {
+      httpRequest.destroy();
+    }
+  }
+
+
+  private appendParams(target: Array<QueryParam>, source: Array<QueryParam>): void {
+    for (let i = 0; i < source.length; i++) {
+      const param = source[i];
+      target.push(new QueryParam(param.key, param.value));
+    }
+  }
+
+  private appendCommonParams(target: Array<QueryParam>): void {
+    target.push(new QueryParam('v', API_VERSION));
+    target.push(new QueryParam('c', CLIENT_NAME));
+    target.push(new QueryParam('f', 'json'));
+  }
+
+  private async buildAuthParams(account: WebDavAccount): Promise<Array<QueryParam>> {
+    const salt = Math.random().toString(36).slice(2, 10);
+    const token = await MD5.digestSync(`${account.password ?? ''}${salt}`);
+    const params: Array<QueryParam> = [];
+    params.push(new QueryParam('u', account.account));
+    params.push(new QueryParam('t', token));
+    params.push(new QueryParam('s', salt));
+    return params;
+  }
+
+  private buildQueryString(params: Array<QueryParam>): string {
+    const parts: Array<string> = [];
+    for (let i = 0; i < params.length; i++) {
+      const param = params[i];
+      if (param.value.length === 0) {
+        continue;
+      }
+      parts.push(`${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`);
+    }
+    return parts.join('&');
+  }
+
+  private buildBaseUrl(account: WebDavAccount): string {
+    const protocol = account.enableHttps ? 'https' : 'http';
+    const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+    const portPart = account.port && account.port !== 80 && account.port !== 443 ? `:${account.port}` : '';
+    const basePath = this.normalizeBasePath(account.navidromeBasePath ?? '/rest');
+    return `${protocol}://${host}${portPart}${basePath}`;
+  }
+
+  private normalizeBasePath(path: string): string {
+    if (!path || path.trim().length === 0) {
+      return '/rest';
+    }
+    let normalized = path.trim();
+    if (!normalized.startsWith('/')) {
+      normalized = `/${normalized}`;
+    }
+    if (normalized.endsWith('/')) {
+      normalized = normalized.slice(0, -1);
+    }
+    return normalized.length === 0 ? '/rest' : normalized;
+  }
+}
+
+export const navidromeApi = new NavidromeApi();

+ 13 - 9
entry/src/main/ets/common/util/MediaTable.ets

@@ -105,14 +105,18 @@ export default  class MediaTable {
         return false;
       }
 
-      // 将完整URL转换为相对路径用于存储
-      const relativePath = WebDavUrlUtil.toStoragePath(item.filePath);
-      if (relativePath !== item.filePath) {
-        // 只有当转换成功时才设置remote_rel_path
-        item.remote_rel_path = relativePath;
-        // 更新filePath为相对路径
-        item.filePath = relativePath;
-        Logger.info(RdbUtils.RDB_TAG, `WebDAV URL转换: 原始URL -> 存储路径: ${relativePath}`);
+      const isPureWebDav = item.type === CommonConstants.TYPE_WEBDAV;
+
+      if (isPureWebDav) {
+        // 将完整URL转换为相对路径用于存储
+        const relativePath = WebDavUrlUtil.toStoragePath(item.filePath);
+        if (relativePath !== item.filePath) {
+          // 只有当转换成功时才设置remote_rel_path
+          item.remote_rel_path = relativePath;
+          // 更新filePath为相对路径
+          item.filePath = relativePath;
+          Logger.info(RdbUtils.RDB_TAG, `WebDAV URL转换: 原始URL -> 存储路径: ${relativePath}`);
+        }
       }
 
       const exists = await this.existsByFilePath(item.filePath);
@@ -1090,4 +1094,4 @@ function generateBucket(item: VideoItem): relationalStore.ValuesBucket {
   }
 
   return obj;
-}
+}

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

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

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

@@ -17,6 +17,7 @@ import { GlobalContext } from '@pura/harmony-utils';
 import { MusicInfo, parseMusicFileName, Utility } from './Utility';
 import { RemoteDriveType } from '../enums/RemoteDriveType';
 import { listSmbDirectory, SmbDirectoryEntry } from '../network/SmbBridge';
+import { NavidromeApi, NavidromeAlbumDetail, NavidromeArtist, NavidromeArtistDetail, NavidromeSong } from '../network/NavidromeApi';
 
 const TAG = 'heanup RemoteDriveManager';
 
@@ -78,6 +79,10 @@ export class RemoteDriveManager {
   // 上传队列
   public uploadQueue: TransferTask[] = [];
   public finishUploadQueue: TransferTask[] = [];
+
+  private navidromeApi: NavidromeApi = new NavidromeApi();
+  private pathDisplayNames: Map<string, string> = new Map();
+  private lastAccountId: number | null = null;
   public isProcessingUploadQueue: boolean = false;
   public currentUploadTask: TransferTask | null = null;
   public isPauseUpload: boolean = true;
@@ -164,12 +169,19 @@ 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) ?? '';
         }
+        if (navBaseIndex >= 0) {
+          const stored = resultSet.getString(navBaseIndex);
+          account.navidromeBasePath = stored && stored.length > 0 ? stored : '/rest';
+        } else {
+          account.navidromeBasePath = '/rest';
+        }
         account.isActivate = resultSet.getLong(resultSet.getColumnIndex('isActivate')) === 1;
 
         resultSet.close();
@@ -430,7 +442,8 @@ export class RemoteDriveManager {
       coverPath TEXT,
       webType INTEGER DEFAULT 0,
       smbShare TEXT,
-      smbDomain TEXT
+      smbDomain TEXT,
+      navidromeBasePath TEXT
     )`;
 
     return this.dataBaseUtil.executeSql(createTableSql)
@@ -489,6 +502,15 @@ export class RemoteDriveManager {
     } catch (error) {
       Logger.info(TAG, 'smbDomain字段可能已存在或添加失败,继续运行');
     }
+
+    try {
+      Logger.info(TAG, '尝试添加navidromeBasePath字段...');
+      const addNavBaseSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN navidromeBasePath TEXT`;
+      await this.dataBaseUtil.executeSql(addNavBaseSql);
+      Logger.info(TAG, 'navidromeBasePath字段添加成功');
+    } catch (error) {
+      Logger.info(TAG, 'navidromeBasePath字段可能已存在或添加失败,继续运行');
+    }
   }
 
   // 从数据库查询所有账户
@@ -501,7 +523,7 @@ export class RemoteDriveManager {
       // 先查询基础字段(确保这些字段在旧版本中存在)
       const baseColumns = ['id', 'name', 'isActivate', 'host', 'localHost', 'isUseLocalHost',
         'port', 'filepath', 'imageFilePath', 'lyricFilePath', 'uploadFilePath',
-        'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain'];
+        'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain', 'navidromeBasePath'];
 
       const resultSet = await this.dataBaseUtil.queryData(this.webDavTable, baseColumns, predicates);
 
@@ -537,6 +559,13 @@ export class RemoteDriveManager {
         if (smbDomainIndex >= 0) {
           account.smbDomain = resultSet.getString(smbDomainIndex) ?? '';
         }
+        const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
+        if (navBaseIndex >= 0) {
+          const stored = resultSet.getString(navBaseIndex);
+          account.navidromeBasePath = stored && stored.length > 0 ? stored : '/rest';
+        } else {
+          account.navidromeBasePath = '/rest';
+        }
 
         this.webDavAccounts.push(account);
       }
@@ -569,7 +598,8 @@ export class RemoteDriveManager {
     coverPath?: string,
     webType: number = 0,
     smbShare: string = '',
-    smbDomain: string = ''
+    smbDomain: string = '',
+    navidromeBasePath: string = '/rest'
   ): Promise<void> {
     try {
       const values: relationalStore.ValuesBucket = {
@@ -589,7 +619,8 @@ export class RemoteDriveManager {
         'coverPath': coverPath || null,
         'webType': webType,
         'smbShare': smbShare,
-        'smbDomain': smbDomain
+        'smbDomain': smbDomain,
+        'navidromeBasePath': navidromeBasePath
       };
 
       await this.dataBaseUtil.insertData(this.webDavTable, values);
@@ -625,7 +656,8 @@ export class RemoteDriveManager {
         'coverPath': account.coverPath || null,
         'webType': account.webType,
         'smbShare': account.smbShare,
-        'smbDomain': account.smbDomain
+        'smbDomain': account.smbDomain,
+        'navidromeBasePath': account.navidromeBasePath
       };
 
       const predicates = new relationalStore.RdbPredicates(this.webDavTable);
@@ -697,6 +729,12 @@ export class RemoteDriveManager {
 
   public async loadFilesInfoFromAccount(account: WebDavAccount,customPath?: string): Promise<void> {
     this.currentAccount = account;
+    if (this.lastAccountId !== account.id) {
+      this.pathHistory = [];
+      this.pathDisplayNames.clear();
+      this.registerPathLabel('/', '根目录');
+    }
+    this.lastAccountId = account.id ?? null;
 
     // 更新现有WebDAV歌曲的webdav_account_id字段
     if (account && account.id) {
@@ -712,6 +750,8 @@ export class RemoteDriveManager {
 
       if (account.webType === RemoteDriveType.Smb) {
         await this.loadSmbFiles(account, normalizedFullPath);
+      } else if (account.webType === RemoteDriveType.Navidrome) {
+        await this.loadNavidromeFiles(account, normalizedFullPath);
       } else {
         await this.loadWebDavFiles(account, normalizedFullPath);
       }
@@ -798,6 +838,51 @@ export class RemoteDriveManager {
     Logger.info(TAG, `从SMB获取到 ${entries.length} 个文件/文件夹`);
   }
 
+  private async loadNavidromeFiles(account: WebDavAccount, fullPath: string): Promise<void> {
+    const normalized = this.normalizeFullPath(fullPath);
+    this.registerPathLabel('/', '根目录');
+    const segments = normalized.split('/').filter(part => part.length > 0);
+
+    if (normalized === '/' || segments.length === 0) {
+      const artists: NavidromeArtist[] = await this.navidromeApi.getArtists(account);
+      artists.sort((a, b) => a.name.localeCompare(b.name));
+      this.webDavFiles = artists.map(artist => {
+        const info = this.createNavDirectory(artist.name, `/artist/${artist.id}`);
+        this.registerPathLabel(info.href, artist.name);
+        return info;
+      });
+      this.webDavSongs = [];
+      Logger.info(TAG, `从Navidrome获取到 ${artists.length} 位艺术家`);
+      return;
+    }
+
+    if (segments[0] === 'artist' && segments[1]) {
+      const artistId = segments[1];
+      const detail: NavidromeArtistDetail = await this.navidromeApi.getArtist(account, artistId);
+      this.registerPathLabel(`/artist/${artistId}`, detail.name);
+      this.webDavFiles = detail.albums.map(album => {
+        const info = this.createNavDirectory(album.name, `/album/${album.id}`);
+        this.registerPathLabel(info.href, album.name);
+        return info;
+      });
+      this.webDavSongs = [];
+      Logger.info(TAG, `Navidrome 艺术家 ${detail.name} 包含 ${detail.albums.length} 张专辑`);
+      return;
+    }
+
+    if (segments[0] === 'album' && segments[1]) {
+      const albumId = segments[1];
+      const detail: NavidromeAlbumDetail = await this.navidromeApi.getAlbum(account, albumId);
+      this.registerPathLabel(`/album/${albumId}`, detail.name);
+      this.webDavFiles = detail.songs.map(song => this.createNavSongFileInfo(song, albumId));
+      this.webDavSongs = detail.songs.map(song => this.buildNavidromeVideoItem(song, account, detail));
+      Logger.info(TAG, `Navidrome 专辑 ${detail.name} 包含 ${detail.songs.length} 首歌曲`);
+      return;
+    }
+
+    throw new Error(`不支持的Navidrome路径: ${fullPath}`);
+  }
+
   // 将FileInfo转换为VideoItem
   private fileInfoToVideoItem(fileInfo: FileInfo, account: WebDavAccount): VideoItem {
     if (account.webType === RemoteDriveType.Smb) {
@@ -930,6 +1015,57 @@ export class RemoteDriveManager {
     return fullPath;
   }
 
+  private createNavDirectory(name: string, href: string): FileInfo {
+    const info = new FileInfo('', name, 0, Date.now());
+    info.fileName = name;
+    info.href = this.normalizeFullPath(href);
+    info.isDirectory = true;
+    return info;
+  }
+
+  private createNavSongFileInfo(song: NavidromeSong, albumId: string): FileInfo {
+    const displayName = song.title ?? '未知曲目';
+    const info = new FileInfo('', displayName, song.size ?? 0, Date.now());
+    info.fileName = `${displayName}${song.suffix ? '.' + song.suffix : ''}`;
+    info.href = this.normalizeFullPath(`/album/${albumId}/${song.id}`);
+    info.isDirectory = false;
+    info.contentLength = song.size ?? 0;
+    return info;
+  }
+
+  private buildNavidromeVideoItem(song: NavidromeSong, account: WebDavAccount, album?: NavidromeAlbumDetail): VideoItem {
+    const title = song.title ?? '未知曲目';
+    const fileName = `${title}${song.suffix ? '.' + song.suffix : ''}`;
+    const videoItem = new VideoItem(
+      title,
+      song.id,
+      `navidrome://${account.id ?? 0}/${song.id}`,
+      CommonConstants.TYPE_NAVIDROME,
+      song.size ?? 0,
+      Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
+      undefined,
+      Utility.formatFSize(song.size ?? 0),
+      undefined,
+      song.artist ?? album?.artist ?? Constants.UNKNOWN_ARTIST,
+      album?.name ?? song.album,
+      fileName
+    );
+    videoItem.size = Utility.formatFSize(song.size ?? 0);
+    videoItem.webdav_account_id = account.id?.toString();
+    videoItem.remote_rel_path = song.id;
+    videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
+    videoItem.mimeType = song.contentType;
+    return videoItem;
+  }
+
+  private registerPathLabel(path: string, label: string): void {
+    if (!path || !label) {
+      return;
+    }
+    const normalized = this.normalizeFullPath(path);
+    this.pathDisplayNames.set(normalized, label);
+  }
+
   private normalizeSmbRelativePath(path: string, shareName?: string): string {
     if (!path || path.trim().length === 0) {
       return '/';
@@ -999,6 +1135,7 @@ export class RemoteDriveManager {
     Logger.info(TAG, '目标路径:', folder.href);
 
     // 保存当前路径到历史记录
+    this.registerPathLabel(folder.href, folder.fileName);
     this.pathHistory.push(this.currentPath);
     Logger.info(TAG, '路径历史:', JSON.stringify(this.pathHistory));
 
@@ -1073,8 +1210,11 @@ export class RemoteDriveManager {
     const parts = this.currentPath.split('/').filter(part => part !== '');
     const breadcrumbs = ['根目录'];
 
+    let cumulative = '';
     for (let i = 0; i < parts.length; i++) {
-      breadcrumbs.push(parts[i]);
+      cumulative += `/${parts[i]}`;
+      const label = this.pathDisplayNames.get(cumulative) ?? parts[i];
+      breadcrumbs.push(label);
     }
 
     return breadcrumbs;

+ 114 - 12
entry/src/main/ets/dialog/RemoteDriveAccountDialog.ets

@@ -45,6 +45,7 @@ export struct RemoteDriveAccountDialog {
   @State driveType: RemoteDriveType = RemoteDriveType.WebDav;
   @State shareName: string = '';
   @State domain: string = '';
+  @State navidromeBasePath: string = '/rest';
   @State coverPath: string = '';
   @State isDarkMode: boolean = false;
   @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
@@ -53,6 +54,7 @@ export struct RemoteDriveAccountDialog {
 
   private nameCustomized: boolean = false;
   private portCustomized: boolean = false;
+  private navBasePathCustomized: boolean = false;
   private suppressPortChange: boolean = false;
 
   onColorModeChange() {
@@ -76,6 +78,10 @@ export struct RemoteDriveAccountDialog {
       this.domain = this.account.smbDomain ?? '';
       this.coverPath = this.account.coverPath || '';
       this.nameCustomized = true;
+      if (this.driveType === RemoteDriveType.Navidrome) {
+        this.navidromeBasePath = this.normalizeNavidromeBasePath(this.account.navidromeBasePath ?? '/rest');
+        this.navBasePathCustomized = true;
+      }
       Logger.info('heanup RemoteDriveAccountDialog', `编辑模式加载账户: ${this.accountName}, 封面路径: ${this.coverPath}`);
       if (this.coverPath) {
         Logger.info('heanup RemoteDriveAccountDialog', `封面文件是否存在: ${ImagePickerUtil.imageExists(this.coverPath)}`);
@@ -268,6 +274,23 @@ export struct RemoteDriveAccountDialog {
         this.buildHelperText('可选,用于需要域/工作组认证的 SMB 服务器')
       }
 
+      if (this.driveType === RemoteDriveType.Navidrome) {
+        Row({ space: 8 }) {
+          Text('API路径')
+            .fontSize(14)
+            .fontColor($r('app.color.index_tab_font_color'));
+          TextInput({ placeholder: '/rest', text: this.navidromeBasePath })
+            .layoutWeight(1)
+            .maxLines(1)
+            .onChange((value: string) => {
+              this.navidromeBasePath = this.normalizeNavidromeBasePath(value);
+              this.navBasePathCustomized = true;
+            });
+        }
+        .alignItems(VerticalAlign.Center);
+        this.buildHelperText('Navidrome/Subsonic REST 前缀,默认 /rest,可填 /navidrome/rest 等路径')
+      }
+
       // 文件目录
       Row({ space: 8 }) {
         Text('文件目录')
@@ -371,6 +394,7 @@ export struct RemoteDriveAccountDialog {
             updatedAccount.webType = this.driveType;
             updatedAccount.smbShare = this.shareName;
             updatedAccount.smbDomain = this.domain;
+            updatedAccount.navidromeBasePath = this.normalizeNavidromeBasePath(this.navidromeBasePath);
             this.onConfirm?.(updatedAccount);
 
           });
@@ -394,6 +418,7 @@ export struct RemoteDriveAccountDialog {
       Row({ space: 12 }) {
         this.buildTypeButton('WebDAV', RemoteDriveType.WebDav);
         this.buildTypeButton('SMB', RemoteDriveType.Smb);
+        this.buildTypeButton('Navidrome', RemoteDriveType.Navidrome);
       }
       .width('100%');
     }
@@ -426,6 +451,9 @@ export struct RemoteDriveAccountDialog {
     if (!this.nameCustomized) {
       this.accountName = this.getDefaultAccountName(type);
     }
+    if (type === RemoteDriveType.Navidrome && !this.navBasePathCustomized) {
+      this.navidromeBasePath = '/rest';
+    }
     this.updatePortForType(type, forcePort);
   }
 
@@ -437,11 +465,22 @@ export struct RemoteDriveAccountDialog {
       this.updatePortState(445, false);
       return;
     }
+    if (type === RemoteDriveType.Navidrome) {
+      this.updatePortState(this.enableHttps ? 443 : 4533, false);
+      return;
+    }
     this.updatePortState(this.enableHttps ? 443 : 5005, false);
   }
 
   private getDefaultAccountName(type: RemoteDriveType): string {
-    return type === RemoteDriveType.Smb ? '新建SMB' : '新建WebDAV';
+    switch (type) {
+      case RemoteDriveType.Smb:
+        return '新建SMB';
+      case RemoteDriveType.Navidrome:
+        return '新建Navidrome';
+      default:
+        return '新建WebDAV';
+    }
   }
 
   private getServerLabel(): string {
@@ -449,15 +488,23 @@ export struct RemoteDriveAccountDialog {
   }
 
   private getServerHint(): string {
-    return this.driveType === RemoteDriveType.Smb
-      ? '支持 smb://user:pass@host/share 输入,自动拆分账户、共享、路径'
-      : '支持 https://user:pass@host:port/path 输入,自动填充协议、端口和目录';
+    switch (this.driveType) {
+      case RemoteDriveType.Smb:
+        return '支持 smb://user:pass@host/share 输入,自动拆分账户、共享、路径';
+      case RemoteDriveType.Navidrome:
+        return '可粘贴 Navidrome/Subsonic 连接(如 https://user:pass@host:4533/rest),自动填充参数';
+      default:
+        return '支持 https://user:pass@host:port/path WebDAV 连接串,自动填充账户、端口和目录';
+    }
   }
 
   private getPortPlaceholder(): string {
     if (this.driveType === RemoteDriveType.Smb) {
       return '默认: 445';
     }
+    if (this.driveType === RemoteDriveType.Navidrome) {
+      return this.enableHttps ? '默认: 443' : '默认: 4533';
+    }
     return this.enableHttps ? '默认: 443' : '默认: 5005';
   }
 
@@ -483,21 +530,36 @@ export struct RemoteDriveAccountDialog {
     if (!parsed) {
       return false;
     }
-    if (parsed.protocol === 'http' || parsed.protocol === 'https') {
-      this.applyParsedWebDav(parsed);
-      ToastUtil.showToast('已解析 WebDAV 连接');
-      return true;
-    }
-    if (parsed.protocol === 'smb' || parsed.protocol === 'cifs') {
+    const protocol = parsed.protocol.toLowerCase();
+    if (protocol === 'smb' || protocol === 'cifs') {
+      if (this.driveType !== RemoteDriveType.Smb) {
+        this.handleDriveTypeChange(RemoteDriveType.Smb);
+      }
       this.applyParsedSmb(parsed);
       ToastUtil.showToast('已解析 SMB 连接');
       return true;
     }
+    if (protocol === 'http' || protocol === 'https') {
+      const looksNav = this.guessNavidromePath(parsed.path);
+      if (looksNav || this.driveType === RemoteDriveType.Navidrome) {
+        if (this.driveType !== RemoteDriveType.Navidrome) {
+          this.handleDriveTypeChange(RemoteDriveType.Navidrome);
+        }
+        this.applyParsedNavidrome(parsed);
+        ToastUtil.showToast('已解析 Navidrome 连接');
+        return true;
+      }
+      if (this.driveType !== RemoteDriveType.WebDav) {
+        this.handleDriveTypeChange(RemoteDriveType.WebDav);
+      }
+      this.applyParsedWebDav(parsed);
+      ToastUtil.showToast('已解析 WebDAV 连接');
+      return true;
+    }
     return false;
   }
 
   private applyParsedWebDav(parsed: ParsedConnectionParts): void {
-    this.handleDriveTypeChange(RemoteDriveType.WebDav);
     this.enableHttps = parsed.protocol === 'https';
     this.host = parsed.host;
     if (parsed.port) {
@@ -515,7 +577,6 @@ export struct RemoteDriveAccountDialog {
   }
 
   private applyParsedSmb(parsed: ParsedConnectionParts): void {
-    this.handleDriveTypeChange(RemoteDriveType.Smb);
     this.host = parsed.host;
     if (parsed.port) {
       this.updatePortState(parsed.port, true);
@@ -534,6 +595,47 @@ export struct RemoteDriveAccountDialog {
     this.filepath = relative ? `/${relative}` : '/';
   }
 
+  private applyParsedNavidrome(parsed: ParsedConnectionParts): void {
+    this.enableHttps = parsed.protocol === 'https';
+    this.host = parsed.host;
+    if (parsed.port) {
+      this.updatePortState(parsed.port, true);
+    } else {
+      this.updatePortForType(RemoteDriveType.Navidrome, true);
+    }
+    if (parsed.username) {
+      this.username = parsed.username;
+    }
+    if (parsed.password) {
+      this.password = parsed.password;
+    }
+    this.navidromeBasePath = this.normalizeNavidromeBasePath(parsed.path ?? this.navidromeBasePath);
+    this.navBasePathCustomized = true;
+    this.filepath = '/';
+  }
+
+  private guessNavidromePath(path?: string): boolean {
+    if (!path) {
+      return false;
+    }
+    const lower = path.toLowerCase();
+    return lower.includes('/rest') || lower.includes('navidrome');
+  }
+
+  private normalizeNavidromeBasePath(value: string): string {
+    if (!value || value.trim().length === 0) {
+      return '/rest';
+    }
+    let normalized = value.trim();
+    if (!normalized.startsWith('/')) {
+      normalized = `/${normalized}`;
+    }
+    if (normalized.length > 1 && normalized.endsWith('/')) {
+      normalized = normalized.slice(0, -1);
+    }
+    return normalized.length === 0 ? '/rest' : normalized;
+  }
+
   private parseConnectionString(input: string): ParsedConnectionParts | null {
     const pattern = /^([a-z][a-z0-9+\-.]*):\/\/(?:([^:@\/]+)(?::([^@\/]*))?@)?([^\/:]+)(?::(\d+))?(\/.*)?$/i;
     const match = input.match(pattern);

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

@@ -1677,7 +1677,8 @@ struct NewIndex {
             account.coverPath,
             account.webType,
             account.smbShare,
-            account.smbDomain
+            account.smbDomain,
+            account.navidromeBasePath
           ).then(() => {
              ToastUtil.showToast('添加成功')
              // 重新加载网盘账户列表 观察着模式去更新了,所以这里不需要更新,先注释掉

+ 7 - 0
entry/src/main/ets/pages/WebDavMainPage.ets

@@ -15,6 +15,7 @@ import { LazyDataSource } from '../common/util/LazyDataSource';
 import { PreferencesUtil } from '@pura/harmony-utils';
 import { ButtonFancyModifier, ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil';
 import { getRemoteDriveAccountLabel, getRemoteDriveDisplayLabel, getRemoteDrivePlaylistPrefix } from '../common/util/RemoteDriveLabel';
+import { RemoteDriveType } from '../common/enums/RemoteDriveType';
 
 /**
  * 歌单播放事件数据
@@ -115,6 +116,12 @@ export struct WebDavMainPage {
       }
 
       const allFolders = this.webDavFiles.filter(f => f.isDirectory);
+      const isNavAccount = this.selectedAccount?.webType === RemoteDriveType.Navidrome;
+      if (isNavAccount) {
+        const sortedNavFolders = allFolders.sort((a, b) => a.fileName.localeCompare(b.fileName));
+        this.visibleFoldersState = sortedNavFolders;
+        return;
+      }
       const visible: FileInfo[] = [];
 
       for (let i = 0; i < allFolders.length; i++) {

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

@@ -80,6 +80,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 { navidromeApi } from '../common/network/NavidromeApi';
 
 import { HSBColorPicker, HSBColorPickerLayout } from '@keke/color-picker'
 import app from '@system.app';
@@ -150,6 +151,7 @@ function getTypeOrder(type: number) {
       return 3; // Last
     case CommonConstants.TYPE_WEBDAV:
     case CommonConstants.TYPE_SMB:
+    case CommonConstants.TYPE_NAVIDROME:
       return 3;
     default:
       return 4; // Unknown types, if any, go last
@@ -164,8 +166,12 @@ function isSmbType(type: number): boolean {
   return type === CommonConstants.TYPE_SMB;
 }
 
+function isNavidromeType(type: number): boolean {
+  return type === CommonConstants.TYPE_NAVIDROME;
+}
+
 function isRemoteCloudType(type: number): boolean {
-  return isWebDavType(type) || isSmbType(type);
+  return isWebDavType(type) || isSmbType(type) || isNavidromeType(type);
 }
 
 function getShareNameFromFilePath(filePath?: string): string | undefined {
@@ -257,6 +263,24 @@ async function setVideoUrlForSong(song: VideoItem): Promise<string> {
     }
   }
 
+  if (isNavidromeType(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('Navidrome账号不可用');
+      }
+      const navSongId = song.remote_rel_path || song.id || song.filePath;
+      const streamUrl = await navidromeApi.buildStreamUrl(account, navSongId);
+      Logger.info(TAG, `Navidrome 流地址构建成功: ${streamUrl}`);
+      return streamUrl.replace(/ /g, '%20');
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `Navidrome URL构建失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
   if (isSmbType(song.type) && song.webdav_account_id) {
     try {
       const manager = RemoteDriveManager.getInstance();
@@ -283,6 +307,9 @@ async function setVideoUrlForSong(song: VideoItem): Promise<string> {
     Logger.warn(TAG, `SMB歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
     return song.filePath;
   }
+  if (isNavidromeType(song.type)) {
+    throw new Error('Navidrome歌曲缺少webdav_account_id,无法构建播放链接');
+  }
   return song.filePath;
 }
 
@@ -6076,6 +6103,7 @@ export struct LocalMusic {
 
       case CommonConstants.TYPE_WEBDAV:
       case CommonConstants.TYPE_SMB:
+      case CommonConstants.TYPE_NAVIDROME:
         // 处理网络音频播放(WebDAV/SMB)
         Logger.info(`heanup 处理云端音频播放: ${item.name}, URL: ${item.filePath}`)
 

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

@@ -27,6 +27,7 @@ export class WebDavAccount{
   public webType : number = RemoteDriveType.WebDav
   public smbShare: string = ''
   public smbDomain: string = ''
+  public navidromeBasePath: string = '/rest'
 
   public setIsUseLocalHost(isuse: boolean){
     this.isUseLocalHost = isuse