瀏覽代碼

feat(remote): 添加Emby流媒体支持

- 在CommonConstants中添加TYPE_EMBY常量
- 在RemoteDriveType枚举中添加Emby类型
- 新增EmbyApi类实现Emby流媒体API接口
- 新增EmbyFileCache实现Emby文件缓存策略
- 更新RemoteSongCache支持EMBY缓存类型
- 在RemoteDriveLabel中添加Emby标签显示
- 扩展RemoteDriveManager支持Emby账号管理
- 添加embyBasePath数据库字段存储Emby基础路径
- 实现Emby音乐文件浏览和播放功能
- 在远程驱动账户对话框中添加Emby路径配置
chendeben 7 月之前
父節點
當前提交
604d9737fe

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

@@ -200,6 +200,7 @@ export class CommonConstants {
   static readonly TYPE_FTP: number = 6;//FTP文件
   static readonly TYPE_BAIDU: number = 7;//百度网盘文件
   static readonly TYPE_JELLYFIN: number = 8;//Jellyfin流媒体文件
+  static readonly TYPE_EMBY: number = 9;//Emby流媒体文件
 
   static readonly TYPE_LOCK: number = 200;//私密视频
   static readonly TYPE_LIKE: number = 201;//我收藏的视频

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

@@ -8,4 +8,5 @@ export enum RemoteDriveType {
   DISK_123 = 6,
   DISK_115 = 7,
   Jellyfin = 8,
+  Emby = 9,
 }

+ 513 - 0
entry/src/main/ets/common/network/EmbyApi.ets

@@ -0,0 +1,513 @@
+import { http } from '@kit.NetworkKit';
+import { PreferencesUtil } from '@pura/harmony-utils';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import { ServerLogUtil } from '../util/ServerLogUtil';
+
+const TAG = 'heanup EmbyApi';
+const CLIENT_NAME = 'TTMusic';
+const CLIENT_VERSION = '1.0.0';
+const DEVICE_NAME = 'HarmonyOS';
+const DEVICE_ID_KEY = 'emby_device_id';
+
+interface EmbyAuthResponse {
+  AccessToken?: string;
+  User?: EmbyUser;
+}
+
+interface EmbyUser {
+  Id?: string;
+  Name?: string;
+}
+
+interface EmbyAuthContext {
+  token: string;
+  userId: string;
+  deviceId: string;
+  userName?: string;
+}
+
+interface EmbyItemImageTags {
+  Primary?: string;
+}
+
+interface EmbyItem {
+  Id?: string;
+  Name?: string;
+  Type?: string;
+  Album?: string;
+  AlbumId?: string;
+  Artists?: string[];
+  ArtistItems?: Array<EmbyPerson>;
+  AlbumArtists?: Array<EmbyPerson>;
+  RunTimeTicks?: number;
+  ProductionYear?: number;
+  IndexNumber?: number;
+  ImageTags?: EmbyItemImageTags;
+  MediaSources?: Array<EmbyMediaSource>;
+}
+
+interface EmbyItemsResponse {
+  Items?: EmbyItem[];
+  TotalRecordCount?: number;
+}
+
+interface EmbyPerson {
+  Id?: string;
+  Name?: string;
+}
+
+interface EmbyMediaStream {
+  Type?: string;
+  Codec?: string;
+  Index?: number;
+  BitRate?: number;
+  SampleRate?: number;
+  Channels?: number;
+}
+
+interface EmbyMediaSource {
+  Id?: string;
+  MediaStreams?: EmbyMediaStream[];
+  Size?: number;
+  Container?: string;
+}
+
+interface EmbyLyricTrackEvent {
+  Text?: string;
+  StartPositionTicks?: number;
+}
+
+interface EmbyLyricData {
+  TrackEvents?: EmbyLyricTrackEvent[];
+}
+
+interface EmbyAuthRequestBody {
+  Username: string;
+  Pw: string;
+}
+
+interface EmbyAuthHeaders {
+  Authorization: string;
+  'X-Emby-Token': string;
+  Accept: string;
+}
+
+interface EmbyLoginHeaders {
+  'Content-Type': string;
+  Accept: string;
+  Authorization: string;
+}
+
+class QueryParam {
+  key: string;
+  value: string;
+
+  constructor(key: string, value: string) {
+    this.key = key;
+    this.value = value;
+  }
+}
+
+export interface EmbyArtist {
+  id: string;
+  name: string;
+  albumCount?: number;
+}
+
+export interface EmbyAlbum {
+  id: string;
+  name: string;
+  artist?: string;
+  songCount?: number;
+  year?: number;
+}
+
+export interface EmbySong {
+  id: string;
+  title: string;
+  album?: string;
+  albumId?: string;
+  artist?: string;
+  artistId?: string;
+  durationSeconds?: number;
+  size?: number;
+  suffix?: string;
+  bitRate?: number;
+  sampleRate?: number;
+  track?: number;
+  year?: number;
+  lyricIndex?: number; // 歌词流索引
+}
+
+export class EmbyApi {
+  private authCache: Map<string, EmbyAuthContext> = new Map();
+
+  async getArtists(account: WebDavAccount): Promise<EmbyArtist[]> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending')
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, '/Artists', params);
+    const items = response.Items ?? [];
+    return items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const artist: EmbyArtist = {
+          id: item.Id as string,
+          name: item.Name as string
+        };
+        return artist;
+      });
+  }
+
+  async getAlbumArtists(account: WebDavAccount): Promise<EmbyArtist[]> {
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending')
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, '/Artists/AlbumArtists', params);
+    const items = response.Items ?? [];
+    return items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const artist: EmbyArtist = {
+          id: item.Id as string,
+          name: item.Name as string
+        };
+        return artist;
+      });
+  }
+
+  async getArtistAlbums(account: WebDavAccount, artistId: string): Promise<EmbyAlbum[]> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'MusicAlbum'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('ArtistIds', artistId)
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    return items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const album: EmbyAlbum = {
+          id: item.Id as string,
+          name: item.Name as string,
+          artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
+          year: item.ProductionYear
+        };
+        return album;
+      });
+  }
+
+  async getAlbum(account: WebDavAccount, albumId: string): Promise<EmbyAlbum | null> {
+    const auth = await this.ensureAuth(account);
+    const item = await this.get<EmbyItem>(account, `/Users/${auth.userId}/Items/${albumId}`);
+    if (!item || !item.Id || !item.Name) {
+      return null;
+    }
+    const album: EmbyAlbum = {
+      id: item.Id,
+      name: item.Name,
+      artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
+      year: item.ProductionYear
+    };
+    return album;
+  }
+
+  async getAlbumSongs(account: WebDavAccount, albumId: string): Promise<EmbySong[]> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('ParentId', albumId),
+      new QueryParam('SortBy', 'ParentIndexNumber,IndexNumber,SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: EmbySong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    return songs;
+  }
+
+  async buildStreamUrl(account: WebDavAccount, itemId: string): Promise<string> {
+    const baseUrl = this.buildBaseUrl(account);
+    return `${baseUrl}/Audio/${encodeURIComponent(itemId)}/stream?static=true`;
+  }
+
+  async buildPrimaryImageUrl(account: WebDavAccount, itemId: string, width: number = 600, height: number = 600): Promise<string> {
+    const baseUrl = this.buildBaseUrl(account);
+    return `${baseUrl}/Items/${encodeURIComponent(itemId)}/Images/Primary?fillHeight=${height}&fillWidth=${width}`;
+  }
+
+  async getLyric(account: WebDavAccount, itemId: string, lyricIndex: number): Promise<string> {
+    const auth = await this.ensureAuth(account);
+    const baseUrl = this.buildBaseUrl(account);
+    const mediaSourceId = itemId; // 通常 MediaSourceId 与 ItemId 相同
+    const url = `${baseUrl}/Items/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${lyricIndex}/Stream.js`;
+    const httpRequest = http.createHttp();
+    try {
+      const response = await httpRequest.request(url, {
+        method: http.RequestMethod.GET,
+        connectTimeout: 10000,
+        readTimeout: 15000,
+        expectDataType: http.HttpDataType.STRING,
+        header: this.buildAuthHeaderObject(auth)
+      });
+      if (response.responseCode < 200 || response.responseCode >= 300) {
+        throw new Error(`Emby 获取歌词失败: HTTP ${response.responseCode}`);
+      }
+      const lyricData = JSON.parse(response.result as string) as EmbyLyricData;
+      // 转换为标准 LRC 格式
+      return this.convertEmbyLyricToLrc(lyricData);
+    } finally {
+      httpRequest.destroy();
+    }
+  }
+
+  async getAuthHeaders(account: WebDavAccount): Promise<Map<string, string>> {
+    const auth = await this.ensureAuth(account);
+    return this.buildAuthHeaderMap(auth);
+  }
+
+  private convertEmbyLyricToLrc(lyricData: EmbyLyricData): string {
+    if (!lyricData || !lyricData.TrackEvents || !Array.isArray(lyricData.TrackEvents)) {
+      return '';
+    }
+    const lines: string[] = [];
+    lyricData.TrackEvents.forEach((event: EmbyLyricTrackEvent) => {
+      if (event.Text && event.StartPositionTicks !== undefined) {
+        // Ticks 转换为秒(1 tick = 100ns = 0.0000001s)
+        const seconds = event.StartPositionTicks / 10000000;
+        const minutes = Math.floor(seconds / 60);
+        const remainingSeconds = (seconds % 60).toFixed(2);
+        const timeTag = `[${String(minutes).padStart(2, '0')}:${remainingSeconds.padStart(5, '0')}]`;
+        lines.push(`${timeTag}${event.Text}`);
+      }
+    });
+    return lines.join('\n');
+  }
+
+  private async get<T>(account: WebDavAccount, path: string, params?: Array<QueryParam>): Promise<T> {
+    return this.request<T>(account, http.RequestMethod.GET, path, params);
+  }
+
+  private async post<T>(account: WebDavAccount, path: string, body?: object): Promise<T> {
+    return this.request<T>(account, http.RequestMethod.POST, path, undefined, body);
+  }
+
+  private async request<T>(
+    account: WebDavAccount,
+    method: http.RequestMethod,
+    path: string,
+    params?: Array<QueryParam>,
+    body?: object,
+    retry: boolean = true
+  ): Promise<T> {
+    const httpRequest = http.createHttp();
+    try {
+      const auth = await this.ensureAuth(account);
+      const query = this.buildQueryString(params);
+      const url = `${this.buildBaseUrl(account)}${path}${query ? `?${query}` : ''}`;
+      void ServerLogUtil.info(TAG, `${method} ${url}`);
+      const response = await httpRequest.request(url, {
+        method,
+        connectTimeout: 10000,
+        readTimeout: 15000,
+        expectDataType: http.HttpDataType.STRING,
+        header: this.buildAuthHeaderObject(auth),
+        extraData: body ? JSON.stringify(body) : undefined
+      });
+      if (response.responseCode === 401 && retry) {
+        this.invalidateAuth(account);
+        void ServerLogUtil.warn(TAG, `401 重试: ${url}`);
+        return this.request<T>(account, method, path, params, body, false);
+      }
+      if (response.responseCode < 200 || response.responseCode >= 300) {
+        throw new Error(`Emby API 请求失败: HTTP ${response.responseCode}`);
+      }
+      return JSON.parse(response.result as string) as T;
+    } finally {
+      httpRequest.destroy();
+    }
+  }
+
+  private async ensureAuth(account: WebDavAccount): Promise<EmbyAuthContext> {
+    const key = this.getAccountKey(account);
+    const cached = this.authCache.get(key);
+    if (cached) {
+      return cached;
+    }
+    const auth = await this.login(account);
+    this.authCache.set(key, auth);
+    return auth;
+  }
+
+  private invalidateAuth(account: WebDavAccount): void {
+    const key = this.getAccountKey(account);
+    this.authCache.delete(key);
+  }
+
+  private async login(account: WebDavAccount): Promise<EmbyAuthContext> {
+    const httpRequest = http.createHttp();
+    try {
+      const url = `${this.buildBaseUrl(account)}/Users/AuthenticateByName`;
+      const deviceId = this.ensureDeviceId();
+      const headers: EmbyLoginHeaders = {
+        'Content-Type': 'application/json',
+        'Accept': 'application/json',
+        'Authorization': this.buildLoginHeader(deviceId)
+      };
+      const body: EmbyAuthRequestBody = {
+        Username: account.account ?? '',
+        Pw: account.password ?? ''
+      };
+      const response = await httpRequest.request(url, {
+        method: http.RequestMethod.POST,
+        connectTimeout: 10000,
+        readTimeout: 15000,
+        expectDataType: http.HttpDataType.STRING,
+        header: headers,
+        extraData: JSON.stringify(body)
+      });
+      if (response.responseCode < 200 || response.responseCode >= 300) {
+        throw new Error(`Emby 登录失败: HTTP ${response.responseCode}`);
+      }
+      const payload = JSON.parse(response.result as string) as EmbyAuthResponse;
+      const token = payload.AccessToken ?? '';
+      const userId = payload.User?.Id ?? '';
+      if (!token || !userId) {
+        throw new Error('Emby 登录返回缺少 AccessToken 或 UserId');
+      }
+      void ServerLogUtil.info(TAG, `Emby 登录成功 user=${payload.User?.Name ?? ''}`);
+      return {
+        token,
+        userId,
+        deviceId,
+        userName: payload.User?.Name
+      };
+    } finally {
+      httpRequest.destroy();
+    }
+  }
+
+  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 && ((protocol === 'https' && account.port !== 443) || (protocol === 'http' && account.port !== 80))
+      ? `:${account.port}`
+      : '';
+    const basePath = this.normalizeBasePath(account.embyBasePath);
+    return `${protocol}://${host}${portPart}${basePath}`;
+  }
+
+  private normalizeBasePath(path?: string): string {
+    if (!path || path.trim().length === 0) {
+      return '';
+    }
+    let normalized = path.trim();
+    if (!normalized.startsWith('/')) {
+      normalized = `/${normalized}`;
+    }
+    if (normalized.length > 1 && normalized.endsWith('/')) {
+      normalized = normalized.slice(0, -1);
+    }
+    return normalized === '/' ? '' : normalized;
+  }
+
+  private buildAuthHeaderObject(auth: EmbyAuthContext): EmbyAuthHeaders {
+    const headers: EmbyAuthHeaders = {
+      'Authorization': this.buildAuthorizationHeader(auth),
+      'X-Emby-Token': auth.token,
+      'Accept': 'application/json'
+    };
+    return headers;
+  }
+
+  private buildAuthHeaderMap(auth: EmbyAuthContext): Map<string, string> {
+    const headers = new Map<string, string>();
+    headers.set('Authorization', this.buildAuthorizationHeader(auth));
+    headers.set('X-Emby-Token', auth.token);
+    headers.set('Accept', 'application/json');
+    return headers;
+  }
+
+  private buildAuthorizationHeader(auth: EmbyAuthContext): string {
+    return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${auth.deviceId}", Version="${CLIENT_VERSION}", Token="${auth.token}"`;
+  }
+
+  private buildLoginHeader(deviceId: string): string {
+    return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${deviceId}", Version="${CLIENT_VERSION}"`;
+  }
+
+  private ensureDeviceId(): string {
+    let deviceId = PreferencesUtil.getStringSync(DEVICE_ID_KEY, '');
+    if (!deviceId || deviceId.length === 0) {
+      deviceId = `ttmusic-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+      PreferencesUtil.putSync(DEVICE_ID_KEY, deviceId);
+    }
+    return deviceId;
+  }
+
+  private buildQueryString(params?: Array<QueryParam>): string {
+    if (!params) {
+      return '';
+    }
+    const parts: string[] = [];
+    for (let i = 0; i < params.length; i++) {
+      const param = params[i];
+      if (!param.value || param.value.length === 0) {
+        continue;
+      }
+      parts.push(`${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`);
+    }
+    return parts.join('&');
+  }
+
+  private getAccountKey(account: WebDavAccount): string {
+    return `${account.id ?? ''}|${account.host}|${account.port}|${account.account}`;
+  }
+
+  private toSong(item: EmbyItem): EmbySong | null {
+    if (!item.Id || !item.Name) {
+      return null;
+    }
+    const mediaSource: EmbyMediaSource | undefined = item.MediaSources && item.MediaSources.length > 0
+      ? item.MediaSources[0]
+      : undefined;
+    const audioStream: EmbyMediaStream | undefined = mediaSource?.MediaStreams?.find((stream: EmbyMediaStream) => stream.Type === 'Audio');
+    // 查找歌词流索引
+    const lyricStream: EmbyMediaStream | undefined = mediaSource?.MediaStreams?.find((stream: EmbyMediaStream) =>
+      stream.Type === 'Subtitle' && stream.Codec === 'lrc'
+    );
+    const durationSeconds = item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined;
+    const song: EmbySong = {
+      id: item.Id,
+      title: item.Name,
+      album: item.Album,
+      albumId: item.AlbumId,
+      artist: item.Artists?.[0] ?? item.ArtistItems?.[0]?.Name,
+      artistId: item.ArtistItems?.[0]?.Id,
+      durationSeconds,
+      size: mediaSource?.Size,
+      suffix: mediaSource?.Container,
+      bitRate: audioStream?.BitRate,
+      sampleRate: audioStream?.SampleRate,
+      track: item.IndexNumber,
+      year: item.ProductionYear,
+      lyricIndex: lyricStream?.Index
+    };
+    return song;
+  }
+}
+
+export const embyApi = new EmbyApi();

+ 113 - 0
entry/src/main/ets/common/network/EmbyFileCache.ets

@@ -0,0 +1,113 @@
+import Logger from '../util/Logger';
+import { ServerLogUtil } from '../util/ServerLogUtil';
+import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
+import { findExistingCacheFile, RemoteCacheType, resolveCacheFilePath, writeBufferToFile } from './RemoteSongCache';
+import { RcpSocket } from '../util/RcpSocketUtil';
+import { EmbyApi } from './EmbyApi';
+
+const TAG = 'EmbyFileCache';
+const embyApi = new EmbyApi();
+
+async function findEmbyCache(request: RemoteCacheRequest): Promise<string | null> {
+  const accountId = request.account.id;
+  if (!accountId) {
+    await ServerLogUtil.warn(TAG, 'Emby账号ID缺失,无法查找缓存');
+    return null;
+  }
+  const remotePath = request.remotePath;
+  if (!remotePath || remotePath.length === 0) {
+    await ServerLogUtil.warn(TAG, 'Emby远程路径为空,无法查找缓存');
+    return null;
+  }
+  return findExistingCacheFile(RemoteCacheType.EMBY, accountId, remotePath);
+}
+
+async function triggerEmbyDownload(request: RemoteCacheRequest): Promise<string | null> {
+  const account = request.account;
+  const accountId = account.id;
+  if (!accountId) {
+    await ServerLogUtil.error(TAG, 'Emby账号ID缺失,无法触发下载');
+    return null;
+  }
+  const itemId = request.remotePath;
+  if (!itemId || itemId.length === 0) {
+    await ServerLogUtil.error(TAG, 'Emby项目ID为空,无法触发下载');
+    return null;
+  }
+
+  await ServerLogUtil.info(TAG, `开始下载Emby文件: itemId=${itemId}, accountId=${accountId}`);
+
+  try {
+    const pathInfo = await resolveCacheFilePath(RemoteCacheType.EMBY, accountId, itemId);
+    const cachePath = pathInfo.cachePath;
+
+    // 构建流媒体URL
+    const streamUrl = await embyApi.buildStreamUrl(account, itemId);
+    await ServerLogUtil.info(TAG, `Emby流媒体URL: ${streamUrl}`);
+
+    // 获取认证头
+    const authHeaders = await embyApi.getAuthHeaders(account);
+    const headers: Record<string, string> = {};
+    authHeaders.forEach((value, key) => {
+      headers[key] = value;
+    });
+
+    // 使用 RcpSocket 下载文件
+    const rcpSocket = new RcpSocket();
+    const arrayBuffer = await rcpSocket.RcpSendGetStream(
+      streamUrl,
+      account.account,
+      account.password,
+      account.enableHttps,
+      undefined
+    );
+
+    if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+      await ServerLogUtil.error(TAG, 'Emby下载失败:未获取到数据');
+      return null;
+    }
+
+    // 写入缓存文件
+    await writeBufferToFile(arrayBuffer, cachePath);
+    await ServerLogUtil.info(TAG, `Emby文件下载并缓存成功: ${cachePath}, 大小: ${arrayBuffer.byteLength} 字节`);
+
+    return cachePath;
+  } catch (error) {
+    const err = error as Error;
+    await ServerLogUtil.error(TAG, `Emby下载失败: ${err.message}`);
+    Logger.error(TAG, `下载Emby文件时发生错误: ${err.message}`);
+    return null;
+  }
+}
+
+class EmbyFileCacheStrategy implements RemoteCacheStrategy {
+  readonly type = RemoteCacheType.EMBY;
+
+  async ensure(request: RemoteCacheRequest): Promise<string> {
+    await ServerLogUtil.info(TAG, `Emby缓存策略: 查找或下载文件`);
+
+    // 1. 查找已存在的缓存
+    const existingCache = await findEmbyCache(request);
+    if (existingCache) {
+      await ServerLogUtil.info(TAG, `使用已有Emby缓存: ${existingCache}`);
+      return existingCache;
+    }
+
+    // 2. 缓存不存在,触发下载
+    await ServerLogUtil.info(TAG, `Emby缓存未命中,触发后台下载`);
+    const downloadedPath = await triggerEmbyDownload(request);
+
+    if (!downloadedPath) {
+      // 下载失败,返回原始流媒体URL
+      await ServerLogUtil.warn(TAG, 'Emby下载失败,回退到流式播放');
+      return await embyApi.buildStreamUrl(request.account, request.remotePath);
+    }
+
+    return downloadedPath;
+  }
+}
+
+export const embyFileCacheStrategy = new EmbyFileCacheStrategy();
+
+// 注册策略
+RemoteCacheManager.registerStrategy(embyFileCacheStrategy);

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

@@ -15,7 +15,9 @@ export enum RemoteCacheType {
   WEBDAV = 'webdav',
   SMB = 'smb',
   FTP = 'ftp',
-  BAIDU = 'baidu'
+  BAIDU = 'baidu',
+  JELLYFIN = 'jellyfin',
+  EMBY = 'emby'
 }
 
 export interface CachePathInfo {
@@ -191,6 +193,8 @@ export async function clearAllRemoteCaches(): Promise<void> {
   await clearRemoteCacheByAccount(RemoteCacheType.SMB);
   await clearRemoteCacheByAccount(RemoteCacheType.FTP);
   await clearRemoteCacheByAccount(RemoteCacheType.BAIDU);
+  await clearRemoteCacheByAccount(RemoteCacheType.JELLYFIN);
+  await clearRemoteCacheByAccount(RemoteCacheType.EMBY);
 }
 
 export async function clearWebDavCacheByAccount(accountId?: string | number): Promise<void> {

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

@@ -18,6 +18,8 @@ export function getRemoteDriveProtocolLabel(type?: number): string {
       return 'Navidrome';
     case RemoteDriveType.Jellyfin:
       return 'Jellyfin';
+    case RemoteDriveType.Emby:
+      return 'Emby';
     case RemoteDriveType.Ftp:
       return 'FTP';
     case RemoteDriveType.Baidu:

+ 164 - 2
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -31,6 +31,7 @@ import { http } from '@kit.NetworkKit';
 import { JSON } from '@kit.ArkTS';
 import { simpleCalculateMD5, simplePrecreateFile, simpleUploadFilePart, simpleCreateFile, TaskPrecreateRequest, TaskPrecreateResponse, TaskUploadPartResponse, TaskCreateFileRequest, TaskCreateFileResponse, convertToBaiduPath, simpleLocateUploadServer } from './TaskPoolHelper';
 import { JellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../network/JellyfinApi';
+import { EmbyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../network/EmbyApi';
 
 const TAG = 'heanup RemoteDriveManager';
 
@@ -326,6 +327,7 @@ export class RemoteDriveManager {
 
   private navidromeApi: NavidromeApi = new NavidromeApi();
   private jellyfinApi: JellyfinApi = new JellyfinApi();
+  private embyApi: EmbyApi = new EmbyApi();
   private pathDisplayNames: Map<string, string> = new Map();
   private lastAccountId: number | null = null;
 
@@ -413,6 +415,7 @@ export class RemoteDriveManager {
         const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
         const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
         const jellyfinBaseIndex = resultSet.getColumnIndex('jellyfinBasePath');
+        const embyBaseIndex = resultSet.getColumnIndex('embyBasePath');
         const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
         const baiduAccessIndex = resultSet.getColumnIndex('baiduAccessToken');
         const baiduRefreshIndex = resultSet.getColumnIndex('baiduRefreshToken');
@@ -435,6 +438,12 @@ export class RemoteDriveManager {
         } else {
           account.jellyfinBasePath = '';
         }
+        if (embyBaseIndex >= 0) {
+          const stored = resultSet.getString(embyBaseIndex);
+          account.embyBasePath = stored ?? '';
+        } else {
+          account.embyBasePath = '';
+        }
         if (ftpEncodingIndex >= 0) {
           const encodingValue = resultSet.getString(ftpEncodingIndex);
           account.ftpEncoding = encodingValue && encodingValue.length > 0 ? encodingValue : 'utf-8';
@@ -586,6 +595,27 @@ export class RemoteDriveManager {
       }
     }
 
+    if (currentSong.type === CommonConstants.TYPE_EMBY) {
+      if (currentSong.webdav_account_id) {
+        try {
+          const account = await this.getWebDavAccountById(currentSong.webdav_account_id);
+          if (account) {
+            const authHeaders = await this.embyApi.getAuthHeaders(account);
+            authHeaders.forEach((value, key) => {
+              headers.set(key, value);
+            });
+            Logger.info(TAG, `Emby 认证头已构建`);
+          } else {
+            Logger.warn(TAG, `Emby 账号不可用: ${currentSong.webdav_account_id}`);
+          }
+        } catch (error) {
+          Logger.error(TAG, `Emby 认证头构建失败: ${(error as Error).message}`);
+        }
+      } else {
+        Logger.warn(TAG, 'Emby歌曲缺少webdav_account_id,无法构建认证头');
+      }
+    }
+
     if (currentSong.type === CommonConstants.TYPE_BAIDU) {
       headers.set('User-Agent', BaiduConstants.STREAMING_USER_AGENT);
       headers.set('Host', 'pan.baidu.com');
@@ -617,7 +647,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}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU}, ${CommonConstants.TYPE_JELLYFIN})
+        WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU}, ${CommonConstants.TYPE_JELLYFIN}, ${CommonConstants.TYPE_EMBY})
         AND (webdav_account_id IS NULL OR webdav_account_id = '')
       `;
 
@@ -777,6 +807,7 @@ export class RemoteDriveManager {
       smbDomain TEXT,
       navidromeBasePath TEXT,
       jellyfinBasePath TEXT,
+      embyBasePath TEXT,
       ftpEncoding TEXT
     )`;
 
@@ -849,6 +880,14 @@ export class RemoteDriveManager {
     } catch (error) {
     }
 
+    try {
+      Logger.info(TAG, '尝试添加embyBasePath字段...');
+      const addEmbyBaseSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN embyBasePath TEXT`;
+      await this.dataBaseUtil.executeSql(addEmbyBaseSql);
+      Logger.info(TAG, 'embyBasePath字段添加成功');
+    } catch (error) {
+    }
+
     try {
       Logger.info(TAG, '尝试添加ftpEncoding字段...');
       const addFtpEncodingSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN ftpEncoding TEXT`;
@@ -877,7 +916,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', 'jellyfinBasePath', 'ftpEncoding',
+        'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain', 'navidromeBasePath', 'jellyfinBasePath', 'embyBasePath', 'ftpEncoding',
         'baiduAccessToken', 'baiduRefreshToken', 'baiduTokenExpiresAt'];
 
       const resultSet = await this.dataBaseUtil.queryData(this.webDavTable, baseColumns, predicates);
@@ -928,6 +967,13 @@ export class RemoteDriveManager {
         } else {
           account.jellyfinBasePath = '';
         }
+        const embyBaseIndex = resultSet.getColumnIndex('embyBasePath');
+        if (embyBaseIndex >= 0) {
+          const stored = resultSet.getString(embyBaseIndex);
+          account.embyBasePath = stored ?? '';
+        } else {
+          account.embyBasePath = '';
+        }
         const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
         if (ftpEncodingIndex >= 0) {
           const encodingValue = resultSet.getString(ftpEncodingIndex);
@@ -982,6 +1028,7 @@ export class RemoteDriveManager {
     smbDomain: string = '',
     navidromeBasePath: string = '/rest',
     jellyfinBasePath: string = '',
+    embyBasePath: string = '',
     ftpEncoding: string = 'utf-8',
     baiduAccessToken: string = '',
     baiduRefreshToken: string = '',
@@ -1008,6 +1055,7 @@ export class RemoteDriveManager {
         'smbDomain': smbDomain,
         'navidromeBasePath': navidromeBasePath,
         'jellyfinBasePath': jellyfinBasePath,
+        'embyBasePath': embyBasePath,
         'ftpEncoding': ftpEncoding,
         'baiduAccessToken': baiduAccessToken,
         'baiduRefreshToken': baiduRefreshToken,
@@ -1050,6 +1098,7 @@ export class RemoteDriveManager {
         'smbDomain': account.smbDomain,
         'navidromeBasePath': account.navidromeBasePath,
         'jellyfinBasePath': account.jellyfinBasePath,
+        'embyBasePath': account.embyBasePath,
         'ftpEncoding': account.ftpEncoding,
         'baiduAccessToken': account.baiduAccessToken,
         'baiduRefreshToken': account.baiduRefreshToken,
@@ -1150,6 +1199,8 @@ export class RemoteDriveManager {
         await this.loadNavidromeFiles(account, normalizedFullPath);
       } else if (account.webType === RemoteDriveType.Jellyfin) {
         await this.loadJellyfinFiles(account, normalizedFullPath);
+      } else if (account.webType === RemoteDriveType.Emby) {
+        await this.loadEmbyFiles(account, normalizedFullPath);
       } else if (account.webType === RemoteDriveType.Ftp) {
         await this.loadFtpFiles(account, normalizedFullPath);
       } else if (account.webType === RemoteDriveType.Baidu) {
@@ -1730,6 +1781,64 @@ export class RemoteDriveManager {
     throw new Error(`不支持的Jellyfin路径: ${fullPath}`);
   }
 
+  private async loadEmbyFiles(account: WebDavAccount, fullPath: string): Promise<void> {
+    const normalized = this.normalizeFullPath(fullPath);
+    this.registerPathLabel('/', '根目录');
+    const segments = normalized.split('/').filter(part => part.length > 0);
+    void ServerLogUtil.info(TAG, `浏览 Emby 路径: ${normalized} account=${ServerLogUtil.sanitizeAccount(account)}`);
+
+    if (normalized === '/' || segments.length === 0) {
+      const artists: EmbyArtist[] = await this.embyApi.getArtists(account);
+      artists.sort((a, b) => a.name.localeCompare(b.name));
+      this.webDavFiles = artists.map(artist => {
+        const info = this.createEmbyDirectory(artist.name, `/artist/${artist.id}`);
+        this.registerPathLabel(info.href, artist.name);
+        return info;
+      });
+      this.webDavSongs = [];
+      void ServerLogUtil.info(TAG, `获取 Emby 艺术家列表,数量: ${artists.length}`);
+      return;
+    }
+
+    if (segments[0] === 'artist' && segments[1]) {
+      const artistId = segments[1];
+      const albums: EmbyAlbum[] = await this.embyApi.getArtistAlbums(account, artistId);
+      const artistLabel = this.pathDisplayNames.get(`/artist/${artistId}`) ?? (albums[0]?.artist ?? '艺术家');
+      if (artistLabel) {
+        this.registerPathLabel(`/artist/${artistId}`, artistLabel);
+      }
+      this.webDavFiles = albums.map(album => {
+        const info = this.createEmbyDirectory(album.name, `/album/${album.id}`);
+        this.registerPathLabel(info.href, album.name);
+        return info;
+      });
+      this.webDavSongs = [];
+      void ServerLogUtil.info(TAG, `进入 Emby 艺术家 ${artistLabel},专辑数: ${albums.length}`);
+      return;
+    }
+
+    if (segments[0] === 'album' && segments[1]) {
+      const albumId = segments[1];
+      const album = await this.embyApi.getAlbum(account, albumId);
+      const albumName = album?.name ?? this.pathDisplayNames.get(`/album/${albumId}`) ?? '专辑';
+      this.registerPathLabel(`/album/${albumId}`, albumName);
+      const songs = await this.embyApi.getAlbumSongs(account, albumId);
+      let coverUrl: string | undefined = undefined;
+      try {
+        coverUrl = await this.embyApi.buildPrimaryImageUrl(account, album?.id ?? albumId);
+      } catch (error) {
+        Logger.warn(TAG, `Emby 专辑封面获取失败: ${(error as Error).message}`);
+      }
+      this.webDavFiles = songs.map(song => this.createEmbySongFileInfo(song, albumId));
+      this.webDavSongs = songs.map(song => this.buildEmbyVideoItem(song, account, album, coverUrl));
+      await this.enrichSongsWithDatabase(this.webDavSongs);
+      void ServerLogUtil.info(TAG, `进入 Emby 专辑 ${albumName},歌曲数: ${songs.length}`);
+      return;
+    }
+
+    throw new Error(`不支持的Emby路径: ${fullPath}`);
+  }
+
   private async enrichSongsWithDatabase(videoItems: VideoItem[]): Promise<void> {
     if (!this.context || !videoItems || videoItems.length === 0) {
       return;
@@ -2252,6 +2361,59 @@ export class RemoteDriveManager {
     return videoItem;
   }
 
+  private createEmbyDirectory(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 createEmbySongFileInfo(song: EmbySong, 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 buildEmbyVideoItem(song: EmbySong, account: WebDavAccount, album?: EmbyAlbum | null, coverUrl?: string): VideoItem {
+    const title = song.title ?? '未知曲目';
+    const fileName = `${title}${song.suffix ? '.' + song.suffix : ''}`;
+    const videoItem = new VideoItem(
+      title,
+      song.id,
+      `emby://${account.id ?? 0}/${song.id}`,
+      CommonConstants.TYPE_EMBY,
+      song.size ?? 0,
+      Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
+      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.sampleRate = song.sampleRate ? song.sampleRate.toString() : undefined;
+    videoItem.pixelMapPath = coverUrl;
+    if (song.track !== undefined && song.track !== null) {
+      videoItem.track = song.track.toString();
+    }
+    if (song.year !== undefined && song.year !== null) {
+      videoItem.year = song.year.toString();
+    }
+    // 存储歌词索引
+    if (song.lyricIndex !== undefined && song.lyricIndex !== null) {
+      videoItem.lyricIndex = song.lyricIndex;
+    }
+    return videoItem;
+  }
+
   private registerPathLabel(path: string, label: string): void {
     if (!path || label === undefined) {
       return;

+ 95 - 4
entry/src/main/ets/dialog/RemoteDriveAccountDialog.ets

@@ -59,6 +59,7 @@ export struct RemoteDriveAccountDialog {
   @State domain: string = '';
   @State navidromeBasePath: string = '/rest';
   @State jellyfinBasePath: string = '';
+  @State embyBasePath: string = '';
   @State coverPath: string = '';
   @State isDarkMode: boolean = false;
   @State ftpEncoding: string = 'utf-8';
@@ -86,6 +87,7 @@ export struct RemoteDriveAccountDialog {
   private portCustomized: boolean = false;
   private navBasePathCustomized: boolean = false;
   private jellyfinBasePathCustomized: boolean = false;
+  private embyBasePathCustomized: boolean = false;
   private baiduAuthStateToken: string = '';
   private baiduWebController: webview.WebviewController = new webview.WebviewController();
   private baiduDevicePollTimer: number = 0;
@@ -122,6 +124,10 @@ export struct RemoteDriveAccountDialog {
         this.jellyfinBasePath = this.normalizeJellyfinBasePath(this.account.jellyfinBasePath ?? '');
         this.jellyfinBasePathCustomized = true;
       }
+      if (this.driveType === RemoteDriveType.Emby) {
+        this.embyBasePath = this.normalizeEmbyBasePath(this.account.embyBasePath ?? '');
+        this.embyBasePathCustomized = true;
+      }
       Logger.info('heanup RemoteDriveAccountDialog', `编辑模式加载账户: ${this.accountName}, 封面路径: ${this.coverPath}`);
       if (this.coverPath) {
         Logger.info('heanup RemoteDriveAccountDialog', `封面文件是否存在: ${ImagePickerUtil.imageExists(this.coverPath)}`);
@@ -370,6 +376,23 @@ export struct RemoteDriveAccountDialog {
         this.buildHelperText('Jellyfin 部署在子路径时填写,默认留空表示根路径');
       }
 
+      if (this.driveType === RemoteDriveType.Emby) {
+        Row({ space: 8 }) {
+          Text('API路径')
+            .fontSize(14)
+            .fontColor($r('app.color.index_tab_font_color'));
+          TextInput({ placeholder: '可选,如 /emby', text: this.embyBasePath })
+            .layoutWeight(1)
+            .maxLines(1)
+            .onChange((value: string) => {
+              this.embyBasePath = this.normalizeEmbyBasePath(value);
+              this.embyBasePathCustomized = true;
+            });
+        }
+        .alignItems(VerticalAlign.Center);
+        this.buildHelperText('Emby 部署在子路径时填写,默认留空表示根路径');
+      }
+
       // if (this.driveType === RemoteDriveType.Navidrome) {
       //   Row({ space: 8 }) {
       //     Text('API路径')
@@ -400,7 +423,7 @@ export struct RemoteDriveAccountDialog {
           });
       }
       .alignItems(VerticalAlign.Center)
-      .visibility(this.driveType === RemoteDriveType.Navidrome || this.driveType === RemoteDriveType.Jellyfin
+      .visibility(this.driveType === RemoteDriveType.Navidrome || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby
         ? Visibility.None
         : Visibility.Visible)
       // this.buildHelperText('从共享根开始的路径,例如 /music 或 /音乐/歌单1')
@@ -432,7 +455,7 @@ export struct RemoteDriveAccountDialog {
       }
       .alignItems(VerticalAlign.Center);
 
-      if (this.driveType === RemoteDriveType.WebDav || this.driveType === RemoteDriveType.Jellyfin) {
+      if (this.driveType === RemoteDriveType.WebDav || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby) {
         Row({ space: 12 }) {
           Text('启用HTTPS')
             .fontSize(14)
@@ -442,7 +465,7 @@ export struct RemoteDriveAccountDialog {
             .onChange((isOn: boolean) => {
               this.enableHttps = isOn;
               if (!this.portCustomized &&
-                (this.driveType === RemoteDriveType.WebDav || this.driveType === RemoteDriveType.Jellyfin)) {
+                (this.driveType === RemoteDriveType.WebDav || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby)) {
                 this.updatePortState(this.getDefaultPort(), false);
               }
             });
@@ -501,6 +524,7 @@ export struct RemoteDriveAccountDialog {
             updatedAccount.smbDomain = this.domain;
             updatedAccount.navidromeBasePath = this.normalizeNavidromeBasePath(this.navidromeBasePath);
             updatedAccount.jellyfinBasePath = this.normalizeJellyfinBasePath(this.jellyfinBasePath);
+            updatedAccount.embyBasePath = this.normalizeEmbyBasePath(this.embyBasePath);
             updatedAccount.ftpEncoding = this.ftpEncoding && this.ftpEncoding.length > 0 ? this.ftpEncoding : 'utf-8';
             updatedAccount.baiduAccessToken = this.baiduAccessToken;
             updatedAccount.baiduRefreshToken = this.baiduRefreshToken;
@@ -530,6 +554,7 @@ export struct RemoteDriveAccountDialog {
         this.buildTypeButton('SMB', RemoteDriveType.Smb);
         this.buildTypeButton('Navidrome', RemoteDriveType.Navidrome);
         this.buildTypeButton('Jellyfin', RemoteDriveType.Jellyfin);
+        this.buildTypeButton('Emby', RemoteDriveType.Emby);
         this.buildTypeButton('FTP', RemoteDriveType.Ftp);
         this.buildTypeButton('百度网盘', RemoteDriveType.Baidu);
       }
@@ -574,7 +599,10 @@ export struct RemoteDriveAccountDialog {
     if (type === RemoteDriveType.Jellyfin && !this.jellyfinBasePathCustomized) {
       this.jellyfinBasePath = '';
     }
-    if (type === RemoteDriveType.Jellyfin && (!this.filepath || this.filepath.length === 0)) {
+    if (type === RemoteDriveType.Emby && !this.embyBasePathCustomized) {
+      this.embyBasePath = '';
+    }
+    if ((type === RemoteDriveType.Jellyfin || type === RemoteDriveType.Emby) && (!this.filepath || this.filepath.length === 0)) {
       this.filepath = '/';
     }
     if (type === RemoteDriveType.Ftp && (!this.ftpEncoding || this.ftpEncoding.length === 0)) {
@@ -609,6 +637,8 @@ export struct RemoteDriveAccountDialog {
         return '新建Navidrome';
       case RemoteDriveType.Jellyfin:
         return '新建Jellyfin';
+      case RemoteDriveType.Emby:
+        return '新建Emby';
       case RemoteDriveType.Ftp:
         return '新建FTP';
       case RemoteDriveType.Baidu:
@@ -630,6 +660,8 @@ export struct RemoteDriveAccountDialog {
         return '可粘贴 Navidrome/Subsonic 连接(如 https://user:pass@host:4533/rest),自动填充参数';
       case RemoteDriveType.Jellyfin:
         return '可粘贴 Jellyfin 连接(如 https://user:pass@host:8096/jellyfin),自动填充参数';
+      case RemoteDriveType.Emby:
+        return '可粘贴 Emby 连接(如 https://user:pass@host:8096/emby),自动填充参数';
       case RemoteDriveType.Ftp:
         return '支持 ftp://user:pass@host:21/path 输入,自动填充账户和目录';
       case RemoteDriveType.Baidu:
@@ -653,6 +685,9 @@ export struct RemoteDriveAccountDialog {
     if (this.driveType === RemoteDriveType.Jellyfin) {
       return this.enableHttps ? 8920 : 8096;
     }
+    if (this.driveType === RemoteDriveType.Emby) {
+      return this.enableHttps ? 8920 : 8096;
+    }
     if (this.driveType === RemoteDriveType.Ftp) {
       return 21;
     }
@@ -717,6 +752,11 @@ export struct RemoteDriveAccountDialog {
         ToastUtil.showToast('已解析 Jellyfin 连接');
         return true;
       }
+      if (Number(this.driveType) === RemoteDriveType.Emby) {
+        this.applyParsedEmby(parsed);
+        ToastUtil.showToast('已解析 Emby 连接');
+        return true;
+      }
       if (Number(this.driveType) === RemoteDriveType.Navidrome) {
         this.applyParsedNavidrome(parsed);
         ToastUtil.showToast('已解析 Navidrome 连接');
@@ -740,6 +780,15 @@ export struct RemoteDriveAccountDialog {
         ToastUtil.showToast('已解析 Jellyfin 连接');
         return true;
       }
+      const looksEmby = this.guessEmbyPath(parsed.path);
+      if (looksEmby) {
+        if (Number(this.driveType) !== RemoteDriveType.Emby) {
+          this.handleDriveTypeChange(RemoteDriveType.Emby);
+        }
+        this.applyParsedEmby(parsed);
+        ToastUtil.showToast('已解析 Emby 连接');
+        return true;
+      }
       if (this.driveType !== RemoteDriveType.WebDav) {
         this.handleDriveTypeChange(RemoteDriveType.WebDav);
       }
@@ -832,6 +881,25 @@ export struct RemoteDriveAccountDialog {
     this.filepath = '/';
   }
 
+  private applyParsedEmby(parsed: ParsedConnectionParts): void {
+    this.enableHttps = parsed.protocol === 'https';
+    this.host = parsed.host;
+    if (parsed.port) {
+      this.updatePortState(parsed.port, true);
+    } else {
+      this.updatePortForType(RemoteDriveType.Emby, true);
+    }
+    if (parsed.username) {
+      this.username = parsed.username;
+    }
+    if (parsed.password) {
+      this.password = parsed.password;
+    }
+    this.embyBasePath = this.normalizeEmbyBasePath(parsed.path ?? this.embyBasePath);
+    this.embyBasePathCustomized = true;
+    this.filepath = '/';
+  }
+
   private applyParsedFtp(parsed: ParsedConnectionParts): void {
     this.enableHttps = false;
     this.host = parsed.host;
@@ -864,6 +932,13 @@ export struct RemoteDriveAccountDialog {
     return path.toLowerCase().includes('jellyfin');
   }
 
+  private guessEmbyPath(path?: string): boolean {
+    if (!path) {
+      return false;
+    }
+    return path.toLowerCase().includes('emby');
+  }
+
   private normalizeNavidromeBasePath(value: string): string {
     if (!value || value.trim().length === 0) {
       return '/rest';
@@ -892,6 +967,20 @@ export struct RemoteDriveAccountDialog {
     return normalized === '/' ? '' : normalized;
   }
 
+  private normalizeEmbyBasePath(value: string): string {
+    if (!value || value.trim().length === 0) {
+      return '';
+    }
+    let normalized = value.trim();
+    if (!normalized.startsWith('/')) {
+      normalized = `/${normalized}`;
+    }
+    if (normalized.length > 1 && normalized.endsWith('/')) {
+      normalized = normalized.slice(0, -1);
+    }
+    return normalized === '/' ? '' : normalized;
+  }
+
   private parseConnectionString(input: string): ParsedConnectionParts | null {
     const pattern = /^([a-z][a-z0-9+\-.]*):\/\/(?:([^:@\/]+)(?::([^@\/]*))?@)?([^\/:]+)(?::(\d+))?(\/.*)?$/i;
     const match = input.match(pattern);
@@ -1425,6 +1514,8 @@ export function getCloudDiskIcon(type: number): ResourceStr {
       return $r('app.media.navidrome');
     case RemoteDriveType.Jellyfin:
       return $r('app.media.cloudDisk');
+    case RemoteDriveType.Emby:
+      return $r('app.media.cloudDisk');
     case RemoteDriveType.Baidu:
       return $r('app.media.baiduwp');
     case RemoteDriveType.ALi:

+ 8 - 0
entry/src/main/ets/pages/NewIndex.ets

@@ -1604,6 +1604,13 @@ struct NewIndex {
         .onClick(async () => {
           this.showRemoteDriveAccountDialog(false, undefined, RemoteDriveType.Jellyfin)
         })
+      MenuItem({
+        startIcon: $r('app.media.cloudDisk'),
+        content: $r('app.string.emby')
+      })
+        .onClick(async () => {
+          this.showRemoteDriveAccountDialog(false, undefined, RemoteDriveType.Emby)
+        })
       MenuItem({
         startIcon: $r('app.media.baiduwp'),
         content: '百度网盘'
@@ -2021,6 +2028,7 @@ struct NewIndex {
             account.smbDomain,
             account.navidromeBasePath,
             account.jellyfinBasePath,
+            account.embyBasePath,
             account.ftpEncoding,
             account.baiduAccessToken,
             account.baiduRefreshToken,

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

@@ -168,7 +168,8 @@ export struct WebDavMainPage {
 
       const allFolders = this.webDavFiles.filter(f => f.isDirectory);
       const isFlatFolderAccount = this.selectedAccount?.webType === RemoteDriveType.Navidrome
-        || this.selectedAccount?.webType === RemoteDriveType.Jellyfin;
+        || this.selectedAccount?.webType === RemoteDriveType.Jellyfin
+        || this.selectedAccount?.webType === RemoteDriveType.Emby;
       if (isFlatFolderAccount) {
         const sortedNavFolders = allFolders.sort((a, b) => a.fileName.localeCompare(b.fileName));
         this.visibleFoldersState = sortedNavFolders;

+ 11 - 10
entry/src/main/ets/view/LocalMusic.ets

@@ -1856,10 +1856,7 @@ export struct LocalMusic {
   }
 
   private closeLoadingProgressDialog(): void {
-    if (!this.loadingProgressDialogId) {
-      return;
-    }
-    DialogHelper.closeDialog(this.loadingProgressDialogId);
+    DialogHelper.closeLoading();
     this.loadingProgressDialogId = '';
   }
 
@@ -2931,13 +2928,14 @@ export struct LocalMusic {
 
     // 初始化进度条
     this.progress  = 0;
-    this.loadingProgressDialogId = DialogHelper.showLoadingProgress({
+    DialogHelper.showLoadingProgress({
       progress: this.progress,
       backCancel: false,
       autoCancel: false,
       loadColor: $r('app.color.title_bar_bg'),
       fontColor: $r('app.color.title_bar_bg')
     });
+    this.loadingProgressDialogId = 'dialog_progress';
 
     // 计算处理总数用于进度计算
     const totalItems = uris.length;
@@ -2965,14 +2963,14 @@ export struct LocalMusic {
         // 更新进度
         processedItems++;
         this.progress  = Math.floor((processedItems  / totalItems) * 100);
-        DialogHelper.updateLoading(this.loadingProgressDialogId, ' 正在处理', this.progress);
+        DialogHelper.updateLoading(` 正在处理 ${this.progress}%`, this.progress);
 
       } catch (error) {
         Logger.error(TAG,  'saveVideoDatas failed with err: ' + JSON.stringify(error));
         // 即使出错也更新进度
         processedItems++;
         this.progress  = Math.floor((processedItems  / totalItems) * 100);
-        DialogHelper.updateLoading(this.loadingProgressDialogId, ' 正在处理', this.progress);
+        DialogHelper.updateLoading(` 正在处理 ${this.progress}%`, this.progress);
       }
     }
     // 关闭进度条
@@ -2993,13 +2991,14 @@ export struct LocalMusic {
 
     let newUris: string[] = [];
     this.progress = 0
-    this.loadingProgressDialogId = DialogHelper.showLoadingProgress({
+    DialogHelper.showLoadingProgress({
       progress: this.progress,
       backCancel: false,
       autoCancel: false,
       loadColor: this.themeColor,
       fontColor: this.themeColor
     });
+    this.loadingProgressDialogId = 'dialog_progress';
     // 计算所有文件的总大小
     let totalSize = 0;
     for (let uri of uris) {
@@ -3031,7 +3030,7 @@ export struct LocalMusic {
 
           // 计算总进度
           this.progress = Math.floor((totalRead / totalSize) * 100);
-          DialogHelper.updateLoading(this.loadingProgressDialogId, '正在导入', this.progress);
+          DialogHelper.updateLoading(`正在导入 ${this.progress}%`, this.progress);
 
           len = await fileIo.read(sourceFile.fd, buffer);
         }
@@ -3064,6 +3063,7 @@ export struct LocalMusic {
 
     this.closeLoadingProgressDialog()
     this.isZero = false
+    this.loadingProgressDialogId = ''
 
     // setTimeout(() => {
     //   // 删除目标路径缓存
@@ -7434,7 +7434,8 @@ export struct LocalMusic {
     }
     //内嵌音乐标签不能开启监听
     // this.deletionWatcher?.stop();
-    this.loadingDialogId = DialogHelper.showLoadingDialog()
+    DialogHelper.showLoadingDialog()
+    this.loadingDialogId = 'loading_dialog'
     await PermissionUtil.activatePermission(item.filePath)
     let tempOutPath = ''
     // 如果不是 packName 包下的文件,则直接路径用this.currentPath

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

@@ -71,6 +71,7 @@ export class VideoItem  {
   navArtistId?: string;
   navAlbumId?: string;
   baiduFsId?: string // 百度网盘 fs_id
+  lyricIndex?: number // Emby 歌词流索引
 
   constructor(name: string, id: string, filePath: string, type:number,videoSize:number,cTime: string,
     size?: string,pixelMapPath?: string,artist?: string,album?: string,fileName?: string, lastPlayed?:Date) {

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

@@ -29,6 +29,7 @@ export class WebDavAccount{
   public smbDomain: string = ''
   public navidromeBasePath: string = '/rest'
   public jellyfinBasePath: string = ''
+  public embyBasePath: string = ''
   public baiduAccessToken: string = ''
   public baiduRefreshToken: string = ''
   public baiduTokenExpiresAt: number = 0

+ 4 - 0
entry/src/main/resources/base/element/string.json

@@ -699,6 +699,10 @@
       "name": "navidrome",
       "value": "Navidrome"
     },
+    {
+      "name": "emby",
+      "value": "Emby"
+    },
     {
       "name": "onekey_add_playlist",
       "value": "一键创建歌单"