Quellcode durchsuchen

Merge remote-tracking branch 'origin/master'

onecold vor 6 Monaten
Ursprung
Commit
eef4c5d64b

+ 911 - 0
entry/src/main/ets/common/network/AudioStationApi.ets

@@ -0,0 +1,911 @@
+import { http } from '@kit.NetworkKit';
+import { PreferencesUtil } from '@pura/harmony-utils';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import { ServerLogUtil } from '../util/ServerLogUtil';
+
+const TAG = 'heanup AudioStationApi';
+const DEVICE_ID_KEY = 'audiostation_device_id';
+const DEVICE_NAME = 'HarmonyOS';
+
+interface AudioStationAuthContext {
+  sid: string;
+  did?: string;
+}
+
+interface AudioStationError {
+  code?: number;
+  errors?: Record<string, string>;
+}
+
+interface AudioStationResponse<T> {
+  success: boolean;
+  data?: T;
+  error?: AudioStationError;
+}
+
+interface AudioStationSongAudio {
+  bitrate?: number;
+  channel?: number;
+  codec?: string;
+  container?: string;
+  duration?: number;
+  filesize?: number;
+  frequency?: number;
+}
+
+interface AudioStationSongTag {
+  album?: string;
+  album_artist?: string;
+  artist?: string;
+  genre?: string;
+  track?: number;
+  year?: number;
+}
+
+interface AudioStationSongEntry {
+  id?: string;
+  title?: string;
+  path?: string;
+  additional?: AudioStationSongAdditional;
+}
+
+interface AudioStationSongAdditional {
+  song_audio?: AudioStationSongAudio;
+  song_tag?: AudioStationSongTag;
+}
+
+interface AudioStationSongListData {
+  songs?: AudioStationSongEntry[];
+  offset?: number;
+  total?: number;
+}
+
+interface AudioStationArtistEntry {
+  name?: string;
+}
+
+interface AudioStationArtistListData {
+  artists?: AudioStationArtistEntry[];
+  offset?: number;
+  total?: number;
+}
+
+interface AudioStationAlbumEntry {
+  name?: string;
+  album_artist?: string;
+  display_artist?: string;
+  year?: number;
+}
+
+interface AudioStationAlbumListData {
+  albums?: AudioStationAlbumEntry[];
+  offset?: number;
+  total?: number;
+}
+
+interface AudioStationPlaylistEntry {
+  id?: string;
+  name?: string;
+  type?: string;
+  library?: string;
+  sharing_status?: string;
+  path?: string;
+}
+
+interface AudioStationPlaylistListData {
+  playlists?: AudioStationPlaylistEntry[];
+  offset?: number;
+  total?: number;
+}
+
+interface AudioStationPlaylistSongEntry {
+  id?: string;
+  title?: string;
+  path?: string;
+  additional?: AudioStationSongAdditional;
+}
+
+interface AudioStationPlaylistInfoData {
+  songs?: AudioStationPlaylistSongEntry[];
+  offset?: number;
+  total?: number;
+}
+
+interface AudioStationSearchData {
+  songs?: AudioStationSongEntry[];
+  songTotal?: number;
+  artists?: AudioStationArtistEntry[];
+  artistTotal?: number;
+  albums?: AudioStationAlbumEntry[];
+  albumTotal?: number;
+}
+
+interface AudioStationPagedResponse<T> {
+  items: T[];
+  nextStart: number | null;
+}
+
+interface AudioStationLoginRequestBody {
+  api: string;
+  version: string;
+  method: string;
+  session: string;
+  account: string;
+  passwd: string;
+  enable_device_token: string;
+  device_name: string;
+  device_id: string;
+}
+
+interface AudioStationLoginResponseData {
+  sid?: string;
+  did?: string;
+}
+
+type AudioStationRequestBody = AudioStationLoginRequestBody;
+
+type JsonValue = string | number | boolean | null | Object | Array<JsonValue>;
+
+class QueryParam {
+  key: string;
+  value: string;
+
+  constructor(key: string, value: string) {
+    this.key = key;
+    this.value = value;
+  }
+}
+
+export interface AudioStationArtist {
+  name: string;
+}
+
+export interface AudioStationAlbum {
+  name: string;
+  albumArtist?: string;
+  displayArtist?: string;
+  year?: number;
+}
+
+export interface AudioStationSong {
+  id: string;
+  title?: string;
+  path?: string;
+  album?: string;
+  albumArtist?: string;
+  artist?: string;
+  durationSeconds?: number;
+  size?: number;
+  bitRate?: number;
+  sampleRate?: number;
+  codec?: string;
+  container?: string;
+  track?: number;
+  year?: number;
+}
+
+export interface AudioStationPlaylist {
+  id: string;
+  name: string;
+  type?: string;
+  library?: string;
+  path?: string;
+}
+
+export class AudioStationApi {
+  private authCache: Map<string, AudioStationAuthContext> = new Map();
+
+  async getArtists(account: WebDavAccount, offset: number = 0, limit: number = 200): Promise<AudioStationArtist[]> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/artist.cgi');
+    const artists: AudioStationArtist[] = [];
+    let currentOffset = offset;
+    while (true) {
+      const params = [
+        new QueryParam('api', 'SYNO.AudioStation.Artist'),
+        new QueryParam('version', '3'),
+        new QueryParam('method', 'list'),
+        new QueryParam('library', 'all'),
+        new QueryParam('offset', `${currentOffset}`),
+        new QueryParam('limit', `${limit}`),
+        new QueryParam('sort_by', 'name'),
+        new QueryParam('sort_direction', 'ASC'),
+        new QueryParam('_sid', auth.sid)
+      ];
+      const response = await this.get<AudioStationResponse<AudioStationArtistListData>>(url, params);
+      if (!response.success) {
+        throw new Error(`AudioStation 获取艺术家失败(code=${response.error?.code ?? 'unknown'})`);
+      }
+      const chunk = response.data?.artists ?? [];
+      const mapped = chunk
+        .filter(item => item.name)
+        .map(item => {
+          const artist: AudioStationArtist = { name: item.name as string };
+          return artist;
+        });
+      artists.push(...mapped);
+      const total = response.data?.total ?? artists.length;
+      if (currentOffset + chunk.length >= total || chunk.length === 0) {
+        break;
+      }
+      currentOffset += chunk.length;
+    }
+    return artists;
+  }
+
+  async getArtistsPage(account: WebDavAccount, offset: number = 0, limit: number = 200): Promise<AudioStationPagedResponse<AudioStationArtist>> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/artist.cgi');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Artist'),
+      new QueryParam('version', '3'),
+      new QueryParam('method', 'list'),
+      new QueryParam('library', 'all'),
+      new QueryParam('offset', `${offset}`),
+      new QueryParam('limit', `${limit}`),
+      new QueryParam('sort_by', 'name'),
+      new QueryParam('sort_direction', 'ASC'),
+      new QueryParam('_sid', auth.sid)
+    ];
+    const response = await this.get<AudioStationResponse<AudioStationArtistListData>>(url, params);
+    if (!response.success) {
+      throw new Error(`AudioStation 获取艺术家失败(code=${response.error?.code ?? 'unknown'})`);
+    }
+    const chunk = response.data?.artists ?? [];
+    const items = chunk
+      .filter(item => item.name)
+      .map(item => {
+        const artist: AudioStationArtist = { name: item.name as string };
+        return artist;
+      });
+    const total = response.data?.total ?? items.length;
+    const nextStart = offset + items.length < total ? offset + items.length : null;
+    const result: AudioStationPagedResponse<AudioStationArtist> = {
+      items,
+      nextStart
+    };
+    return result;
+  }
+
+  async getAlbums(account: WebDavAccount, artistName?: string, offset: number = 0, limit: number = 200): Promise<AudioStationAlbum[]> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/album.cgi');
+    const albums: AudioStationAlbum[] = [];
+    let currentOffset = offset;
+    while (true) {
+      const params = [
+        new QueryParam('api', 'SYNO.AudioStation.Album'),
+        new QueryParam('version', '3'),
+        new QueryParam('method', 'list'),
+        new QueryParam('library', 'all'),
+        new QueryParam('offset', `${currentOffset}`),
+        new QueryParam('limit', `${limit}`),
+        new QueryParam('sort_by', 'name'),
+        new QueryParam('sort_direction', 'ASC'),
+        new QueryParam('_sid', auth.sid)
+      ];
+      if (artistName) {
+        params.push(new QueryParam('artist', artistName));
+      }
+      const response = await this.get<AudioStationResponse<AudioStationAlbumListData>>(url, params);
+      if (!response.success) {
+        throw new Error(`AudioStation 获取专辑失败(code=${response.error?.code ?? 'unknown'})`);
+      }
+      const chunk = response.data?.albums ?? [];
+      const mapped = chunk
+        .filter(item => item.name)
+        .map(item => {
+          const album: AudioStationAlbum = {
+            name: item.name as string,
+            albumArtist: item.album_artist,
+            displayArtist: item.display_artist,
+            year: item.year
+          };
+          return album;
+        });
+      albums.push(...mapped);
+      const total = response.data?.total ?? albums.length;
+      if (currentOffset + chunk.length >= total || chunk.length === 0) {
+        break;
+      }
+      currentOffset += chunk.length;
+    }
+    return albums;
+  }
+
+  async getAlbumsPage(
+    account: WebDavAccount,
+    artistName?: string,
+    offset: number = 0,
+    limit: number = 200
+  ): Promise<AudioStationPagedResponse<AudioStationAlbum>> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/album.cgi');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Album'),
+      new QueryParam('version', '3'),
+      new QueryParam('method', 'list'),
+      new QueryParam('library', 'all'),
+      new QueryParam('offset', `${offset}`),
+      new QueryParam('limit', `${limit}`),
+      new QueryParam('sort_by', 'name'),
+      new QueryParam('sort_direction', 'ASC'),
+      new QueryParam('_sid', auth.sid)
+    ];
+    if (artistName) {
+      params.push(new QueryParam('artist', artistName));
+    }
+    const response = await this.get<AudioStationResponse<AudioStationAlbumListData>>(url, params);
+    if (!response.success) {
+      throw new Error(`AudioStation 获取专辑失败(code=${response.error?.code ?? 'unknown'})`);
+    }
+    const chunk = response.data?.albums ?? [];
+    const items = chunk
+      .filter(item => item.name)
+      .map(item => {
+        const album: AudioStationAlbum = {
+          name: item.name as string,
+          albumArtist: item.album_artist,
+          displayArtist: item.display_artist,
+          year: item.year
+        };
+        return album;
+      });
+    const total = response.data?.total ?? items.length;
+    const nextStart = offset + items.length < total ? offset + items.length : null;
+    const result: AudioStationPagedResponse<AudioStationAlbum> = {
+      items,
+      nextStart
+    };
+    return result;
+  }
+
+  async getAlbumSongs(
+    account: WebDavAccount,
+    albumName: string,
+    albumArtist?: string,
+    offset: number = 0,
+    limit: number = 500
+  ): Promise<AudioStationSong[]> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/song.cgi');
+    const songs: AudioStationSong[] = [];
+    let currentOffset = offset;
+    while (true) {
+      const params = [
+        new QueryParam('api', 'SYNO.AudioStation.Song'),
+        new QueryParam('version', '3'),
+        new QueryParam('method', 'list'),
+        new QueryParam('library', 'all'),
+        new QueryParam('offset', `${currentOffset}`),
+        new QueryParam('limit', `${limit}`),
+        new QueryParam('sort_by', 'title'),
+        new QueryParam('sort_direction', 'ASC'),
+        new QueryParam('additional', 'song_tag,song_audio'),
+        new QueryParam('_sid', auth.sid),
+        new QueryParam('album', albumName)
+      ];
+      if (albumArtist) {
+        params.push(new QueryParam('album_artist', albumArtist));
+      }
+      const response = await this.postForm<AudioStationResponse<AudioStationSongListData>>(url, params);
+      if (!response.success) {
+        throw new Error(`AudioStation 获取歌曲失败(code=${response.error?.code ?? 'unknown'})`);
+      }
+      const chunk = response.data?.songs ?? [];
+      songs.push(...chunk
+        .filter(item => item.id)
+        .map(item => {
+          const audio = item.additional?.song_audio;
+          const tag = item.additional?.song_tag;
+          const duration = audio?.duration;
+          const song: AudioStationSong = {
+            id: item.id as string,
+            title: item.title,
+            path: item.path,
+            album: tag?.album,
+            albumArtist: tag?.album_artist,
+            artist: tag?.artist,
+            durationSeconds: duration ? Math.round(duration) : undefined,
+            size: audio?.filesize,
+            bitRate: audio?.bitrate,
+            sampleRate: audio?.frequency,
+            codec: audio?.codec,
+            container: audio?.container,
+            track: tag?.track,
+            year: tag?.year
+          };
+          return song;
+        }));
+      const total = response.data?.total ?? songs.length;
+      if (currentOffset + chunk.length >= total || chunk.length === 0) {
+        break;
+      }
+      currentOffset += chunk.length;
+    }
+    return songs;
+  }
+
+  async getSongs(account: WebDavAccount, offset: number = 0, limit: number = 500): Promise<AudioStationSong[]> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/song.cgi');
+    const songs: AudioStationSong[] = [];
+    let currentOffset = offset;
+    while (true) {
+      const params = [
+        new QueryParam('api', 'SYNO.AudioStation.Song'),
+        new QueryParam('version', '3'),
+        new QueryParam('method', 'list'),
+        new QueryParam('library', 'all'),
+        new QueryParam('offset', `${currentOffset}`),
+        new QueryParam('limit', `${limit}`),
+        new QueryParam('sort_by', 'title'),
+        new QueryParam('sort_direction', 'ASC'),
+        new QueryParam('additional', 'song_tag,song_audio'),
+        new QueryParam('_sid', auth.sid)
+      ];
+      const response = await this.postForm<AudioStationResponse<AudioStationSongListData>>(url, params);
+      if (!response.success) {
+        throw new Error(`AudioStation 获取歌曲失败(code=${response.error?.code ?? 'unknown'})`);
+      }
+      const chunk = response.data?.songs ?? [];
+      songs.push(...chunk
+        .filter(item => item.id)
+        .map(item => {
+          const audio = item.additional?.song_audio;
+          const tag = item.additional?.song_tag;
+          const duration = audio?.duration;
+          const song: AudioStationSong = {
+            id: item.id as string,
+            title: item.title,
+            path: item.path,
+            album: tag?.album,
+            albumArtist: tag?.album_artist,
+            artist: tag?.artist,
+            durationSeconds: duration ? Math.round(duration) : undefined,
+            size: audio?.filesize,
+            bitRate: audio?.bitrate,
+            sampleRate: audio?.frequency,
+            codec: audio?.codec,
+            container: audio?.container,
+            track: tag?.track,
+            year: tag?.year
+          };
+          return song;
+        }));
+      const total = response.data?.total ?? songs.length;
+      if (currentOffset + chunk.length >= total || chunk.length === 0) {
+        break;
+      }
+      currentOffset += chunk.length;
+    }
+    return songs;
+  }
+
+  async getSongsPage(account: WebDavAccount, offset: number = 0, limit: number = 500): Promise<AudioStationPagedResponse<AudioStationSong>> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/song.cgi');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Song'),
+      new QueryParam('version', '3'),
+      new QueryParam('method', 'list'),
+      new QueryParam('library', 'all'),
+      new QueryParam('offset', `${offset}`),
+      new QueryParam('limit', `${limit}`),
+      new QueryParam('sort_by', 'title'),
+      new QueryParam('sort_direction', 'ASC'),
+      new QueryParam('additional', 'song_tag,song_audio'),
+      new QueryParam('_sid', auth.sid)
+    ];
+    const response = await this.postForm<AudioStationResponse<AudioStationSongListData>>(url, params);
+    if (!response.success) {
+      throw new Error(`AudioStation 获取歌曲失败(code=${response.error?.code ?? 'unknown'})`);
+    }
+    const chunk = response.data?.songs ?? [];
+    const items = chunk
+      .filter(item => item.id)
+      .map(item => {
+        const audio = item.additional?.song_audio;
+        const tag = item.additional?.song_tag;
+        const duration = audio?.duration;
+        const song: AudioStationSong = {
+          id: item.id as string,
+          title: item.title,
+          path: item.path,
+          album: tag?.album,
+          albumArtist: tag?.album_artist,
+          artist: tag?.artist,
+          durationSeconds: duration ? Math.round(duration) : undefined,
+          size: audio?.filesize,
+          bitRate: audio?.bitrate,
+          sampleRate: audio?.frequency,
+          codec: audio?.codec,
+          container: audio?.container,
+          track: tag?.track,
+          year: tag?.year
+        };
+        return song;
+      });
+    const total = response.data?.total ?? items.length;
+    const nextStart = offset + items.length < total ? offset + items.length : null;
+    const result: AudioStationPagedResponse<AudioStationSong> = {
+      items,
+      nextStart
+    };
+    return result;
+  }
+
+  async searchSongs(account: WebDavAccount, keyword: string, offset: number = 0, limit: number = 200): Promise<AudioStationSong[]> {
+    const trimmed = keyword.trim();
+    if (trimmed.length === 0) {
+      return [];
+    }
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/search.cgi');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Search'),
+      new QueryParam('version', '1'),
+      new QueryParam('method', 'list'),
+      new QueryParam('library', 'all'),
+      new QueryParam('offset', `${offset}`),
+      new QueryParam('limit', `${limit}`),
+      new QueryParam('keyword', trimmed),
+      new QueryParam('sort_by', 'title'),
+      new QueryParam('sort_direction', 'ASC'),
+      new QueryParam('additional', 'song_tag,song_audio'),
+      new QueryParam('_sid', auth.sid)
+    ];
+    const response = await this.get<AudioStationResponse<AudioStationSearchData>>(url, params);
+    if (!response.success) {
+      throw new Error(`AudioStation 搜索失败(code=${response.error?.code ?? 'unknown'})`);
+    }
+    const chunk = response.data?.songs ?? [];
+    const songs: AudioStationSong[] = [];
+    for (let i = 0; i < chunk.length; i++) {
+      const item = chunk[i];
+      if (!item.id) {
+        continue;
+      }
+      const audio = item.additional?.song_audio;
+      const tag = item.additional?.song_tag;
+      const duration = audio?.duration;
+      const song: AudioStationSong = {
+        id: item.id as string,
+        title: item.title,
+        path: item.path,
+        album: tag?.album,
+        albumArtist: tag?.album_artist,
+        artist: tag?.artist,
+        durationSeconds: duration ? Math.round(duration) : undefined,
+        size: audio?.filesize,
+        bitRate: audio?.bitrate,
+        sampleRate: audio?.frequency,
+        codec: audio?.codec,
+        container: audio?.container,
+        track: tag?.track,
+        year: tag?.year
+      };
+      songs.push(song);
+    }
+    return songs;
+  }
+
+  async getPlaylistsPage(account: WebDavAccount, offset: number = 0, limit: number = 200): Promise<AudioStationPagedResponse<AudioStationPlaylist>> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/playlist.cgi');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Playlist'),
+      new QueryParam('version', '2'),
+      new QueryParam('method', 'list'),
+      new QueryParam('library', 'all'),
+      new QueryParam('offset', `${offset}`),
+      new QueryParam('limit', `${limit}`),
+      new QueryParam('_sid', auth.sid)
+    ];
+    const response = await this.get<AudioStationResponse<AudioStationPlaylistListData>>(url, params);
+    if (!response.success) {
+      throw new Error(`AudioStation 获取歌单失败(code=${response.error?.code ?? 'unknown'})`);
+    }
+    const chunk = response.data?.playlists ?? [];
+    const items = chunk
+      .filter(item => item.id && item.name)
+      .map(item => {
+        const playlist: AudioStationPlaylist = {
+          id: item.id as string,
+          name: item.name as string,
+          type: item.type,
+          library: item.library,
+          path: item.path
+        };
+        return playlist;
+      });
+    const total = response.data?.total ?? items.length;
+    const nextStart = offset + items.length < total ? offset + items.length : null;
+    const result: AudioStationPagedResponse<AudioStationPlaylist> = {
+      items,
+      nextStart
+    };
+    return result;
+  }
+
+  async getPlaylistSongsPage(
+    account: WebDavAccount,
+    playlistId: string,
+    offset: number = 0,
+    limit: number = 200
+  ): Promise<AudioStationPagedResponse<AudioStationSong>> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/playlist.cgi');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Playlist'),
+      new QueryParam('version', '2'),
+      new QueryParam('method', 'getinfo'),
+      new QueryParam('library', 'all'),
+      new QueryParam('id', playlistId),
+      new QueryParam('offset', `${offset}`),
+      new QueryParam('limit', `${limit}`),
+      new QueryParam('sort_direction', 'ASC'),
+      new QueryParam('additional', 'songs_song_tag,songs_song_audio'),
+      new QueryParam('_sid', auth.sid)
+    ];
+    const response = await this.get<AudioStationResponse<AudioStationPlaylistInfoData>>(url, params);
+    if (!response.success) {
+      throw new Error(`AudioStation 获取歌单歌曲失败(code=${response.error?.code ?? 'unknown'})`);
+    }
+    const chunk = response.data?.songs ?? [];
+    const items = chunk
+      .filter(item => item.id)
+      .map(item => {
+        const audio = item.additional?.song_audio;
+        const tag = item.additional?.song_tag;
+        const duration = audio?.duration;
+        const song: AudioStationSong = {
+          id: item.id as string,
+          title: item.title,
+          path: item.path,
+          album: tag?.album,
+          albumArtist: tag?.album_artist,
+          artist: tag?.artist,
+          durationSeconds: duration ? Math.round(duration) : undefined,
+          size: audio?.filesize,
+          bitRate: audio?.bitrate,
+          sampleRate: audio?.frequency,
+          codec: audio?.codec,
+          container: audio?.container,
+          track: tag?.track,
+          year: tag?.year
+        };
+        return song;
+      });
+    const total = response.data?.total ?? items.length;
+    const nextStart = offset + items.length < total ? offset + items.length : null;
+    const result: AudioStationPagedResponse<AudioStationSong> = {
+      items,
+      nextStart
+    };
+    return result;
+  }
+
+  async buildSongCoverUrl(account: WebDavAccount, songId: string | undefined): Promise<string | undefined> {
+    if (!songId) {
+      return undefined;
+    }
+    const auth = await this.ensureAuth(account);
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Cover'),
+      new QueryParam('version', '1'),
+      new QueryParam('method', 'getsongcover'),
+      new QueryParam('library', 'all'),
+      new QueryParam('id', songId),
+      new QueryParam('_sid', auth.sid)
+    ];
+    const url = this.buildWebApiUrl(account, 'AudioStation/cover.cgi');
+    return `${url}?${this.buildQuery(params)}`;
+  }
+
+  async buildAlbumCoverUrl(account: WebDavAccount, albumName?: string, albumArtist?: string): Promise<string | undefined> {
+    if (!albumName) {
+      return undefined;
+    }
+    const auth = await this.ensureAuth(account);
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Cover'),
+      new QueryParam('version', '1'),
+      new QueryParam('method', 'getcover'),
+      new QueryParam('library', 'all'),
+      new QueryParam('album_name', albumName),
+      new QueryParam('_sid', auth.sid)
+    ];
+    if (albumArtist) {
+      params.push(new QueryParam('album_artist_name', albumArtist));
+    }
+    const url = this.buildWebApiUrl(account, 'AudioStation/cover.cgi');
+    return `${url}?${this.buildQuery(params)}`;
+  }
+
+  async buildStreamUrl(account: WebDavAccount, songId: string): Promise<string> {
+    if (!songId) {
+      throw new Error('无效的AudioStation歌曲ID');
+    }
+    const auth = await this.ensureAuth(account);
+    const shouldTranscode = songId.includes('_v_');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Stream'),
+      new QueryParam('version', '2'),
+      new QueryParam('method', shouldTranscode ? 'transcode' : 'stream'),
+      new QueryParam('id', songId),
+      new QueryParam('_sid', auth.sid)
+    ];
+    if (shouldTranscode) {
+      params.push(new QueryParam('format', 'mp3'));
+    }
+    const baseUrl = this.buildWebApiUrl(account, 'AudioStation/stream.cgi');
+    const query = this.buildQuery(params);
+    if (shouldTranscode) {
+      return `${baseUrl}/0.mp3?${query}`;
+    }
+    return `${baseUrl}?${query}`;
+  }
+
+  private async ensureAuth(account: WebDavAccount): Promise<AudioStationAuthContext> {
+    const key = this.buildCacheKey(account);
+    const cached = this.authCache.get(key);
+    if (cached?.sid) {
+      return cached;
+    }
+    const auth = await this.login(account);
+    this.authCache.set(key, auth);
+    return auth;
+  }
+
+  private async login(account: WebDavAccount): Promise<AudioStationAuthContext> {
+    if (!account.account || !account.password) {
+      throw new Error('AudioStation 账号或密码为空');
+    }
+    const url = this.buildWebApiUrl(account, 'entry.cgi');
+    const deviceId = this.getDeviceId();
+    const params = [
+      new QueryParam('api', 'SYNO.API.Auth'),
+      new QueryParam('version', '6'),
+      new QueryParam('method', 'login'),
+      new QueryParam('session', 'audiostation'),
+      new QueryParam('account', account.account),
+      new QueryParam('passwd', account.password),
+      new QueryParam('enable_device_token', 'yes'),
+      new QueryParam('device_name', DEVICE_NAME),
+      new QueryParam('device_id', deviceId)
+    ];
+    const response = await this.postForm<AudioStationResponse<AudioStationLoginResponseData>>(url, params);
+    if (!response.success || !response.data?.sid) {
+      const errorCode = response.error?.code ?? 'unknown';
+      throw new Error(`AudioStation 登录失败(code=${errorCode})`);
+    }
+    const auth: AudioStationAuthContext = {
+      sid: response.data.sid,
+      did: response.data.did
+    };
+    void ServerLogUtil.info(TAG, `AudioStation 登录成功 sid=${auth.sid}`);
+    return auth;
+  }
+
+  private buildCacheKey(account: WebDavAccount): string {
+    return `${account.id ?? account.host}_${account.account}`;
+  }
+
+  private buildBaseUrl(account: WebDavAccount): string {
+    const scheme = account.enableHttps ? 'https' : 'http';
+    const port = account.port || (account.enableHttps ? 5001 : 5000);
+    return `${scheme}://${account.host}:${port}`;
+  }
+
+  private buildWebApiUrl(account: WebDavAccount, path: string): string {
+    const baseUrl = this.buildBaseUrl(account);
+    const normalizedPath = path.startsWith('/') ? path : `/${path}`;
+    return `${baseUrl}/webapi${normalizedPath}`;
+  }
+
+  private buildQuery(params: QueryParam[]): string {
+    return params
+      .filter(param => param.key && param.value !== undefined && param.value !== null)
+      .map(param => `${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`)
+      .join('&');
+  }
+
+  private async get<T>(url: string, params: QueryParam[]): Promise<T> {
+    const httpRequest = http.createHttp();
+    const query = this.buildQuery(params);
+    const response = await httpRequest.request(`${url}?${query}`, {
+      method: http.RequestMethod.GET,
+      header: {
+        Accept: 'application/json'
+      },
+      connectTimeout: 10000,
+      readTimeout: 15000,
+      expectDataType: http.HttpDataType.STRING
+    });
+    if (response.responseCode !== 200) {
+      httpRequest.destroy();
+      throw new Error(`AudioStation 请求失败: HTTP ${response.responseCode}`);
+    }
+    httpRequest.destroy();
+    return this.parseResponse<T>(response.result);
+  }
+
+  private async post<T>(url: string, params: QueryParam[], body?: AudioStationRequestBody): Promise<T> {
+    const httpRequest = http.createHttp();
+    const query = this.buildQuery(params);
+    const response = await httpRequest.request(query ? `${url}?${query}` : url, {
+      method: http.RequestMethod.POST,
+      header: {
+        Accept: 'application/json',
+        'Content-Type': 'application/json'
+      },
+      extraData: body ? JSON.stringify(body) : undefined,
+      connectTimeout: 10000,
+      readTimeout: 15000,
+      expectDataType: http.HttpDataType.STRING
+    });
+    if (response.responseCode !== 200) {
+      httpRequest.destroy();
+      throw new Error(`AudioStation 请求失败: HTTP ${response.responseCode}`);
+    }
+    httpRequest.destroy();
+    return this.parseResponse<T>(response.result);
+  }
+
+  private async postForm<T>(url: string, params: QueryParam[]): Promise<T> {
+    const httpRequest = http.createHttp();
+    const body = this.buildQuery(params);
+    const response = await httpRequest.request(url, {
+      method: http.RequestMethod.POST,
+      header: {
+        Accept: 'application/json',
+        'Content-Type': 'application/x-www-form-urlencoded'
+      },
+      extraData: body,
+      connectTimeout: 10000,
+      readTimeout: 15000,
+      expectDataType: http.HttpDataType.STRING
+    });
+    if (response.responseCode !== 200) {
+      httpRequest.destroy();
+      throw new Error(`AudioStation 请求失败: HTTP ${response.responseCode}`);
+    }
+    httpRequest.destroy();
+    return this.parseResponse<T>(response.result);
+  }
+
+  private parseResponse<T>(payload: string | Object): T {
+    if (typeof payload === 'string') {
+      const trimmed = payload.trim();
+      if (trimmed.length === 0) {
+        return JSON.parse('{}') as T;
+      }
+      try {
+        const parsed: JsonValue = JSON.parse(trimmed) as JsonValue;
+        if (typeof parsed === 'string') {
+          const inner = parsed.trim();
+          if (inner.startsWith('{') || inner.startsWith('[')) {
+            return JSON.parse(inner) as T;
+          }
+        }
+        return parsed as T;
+      } catch (_error) {
+        return payload as T;
+      }
+    }
+    return payload as T;
+  }
+
+  private getDeviceId(): string {
+    const cached = PreferencesUtil.getStringSync(DEVICE_ID_KEY, '');
+    if (cached && cached.length > 0) {
+      return cached;
+    }
+    const id = `ttmusic_${Date.now().toString(36)}_${Math.floor(Math.random() * 100000)}`;
+    PreferencesUtil.putSync(DEVICE_ID_KEY, id);
+    return id;
+  }
+}
+
+export const audioStationApi = new AudioStationApi();

+ 167 - 0
entry/src/main/ets/common/network/AudioStationFileCache.ets

@@ -0,0 +1,167 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+import FileManager from '../util/FileManager';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
+import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
+import { fileIo } from '@kit.CoreFileKit';
+
+const TAG = 'heanup AudioStationFileCache';
+const CHUNK_SIZE = 4 * 1024 * 1024;
+
+interface AudioStationCacheOptions extends RemoteCacheRequest {
+  account: WebDavAccount;
+  songId: string;
+  streamUrl: string;
+  fileSize?: number;
+}
+
+class AudioStationCacheStrategy implements RemoteCacheStrategy {
+  readonly type: RemoteCacheType = RemoteCacheType.AUDIOSTATION;
+
+  async ensure(options: RemoteCacheRequest): Promise<string> {
+    const audioOptions = options as AudioStationCacheOptions;
+    return ensureAudioStationFileCachedInternal(
+      audioOptions.account,
+      audioOptions.songId,
+      audioOptions.streamUrl,
+      audioOptions.fileSize
+    );
+  }
+}
+
+async function ensureAudioStationFileCachedInternal(
+  account: WebDavAccount,
+  songId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  const normalizedRelative = normalizeCacheRelativePath(songId);
+  const pathInfo = await resolveCacheFilePath(
+    RemoteCacheType.AUDIOSTATION,
+    account.id?.toString(),
+    normalizedRelative
+  );
+  const cachePath = pathInfo.cachePath;
+
+  let exists = await FileManager.isExist(cachePath);
+  if (exists) {
+    const size = await FileManager.getFileSize(cachePath);
+    if (size <= 0) {
+      await FileManager.deleteFile(cachePath);
+      exists = false;
+    } else {
+      Logger.info(TAG, `AudioStation 文件已缓存: ${cachePath}, size=${size}`);
+      return cachePath;
+    }
+  }
+
+  if (!exists) {
+    try {
+      Logger.info(TAG, `开始下载 AudioStation 文件: songId=${songId}, 到 ${cachePath}`);
+      let finalFileSize = fileSize || 0;
+      if (!finalFileSize || finalFileSize <= 0) {
+        try {
+          const headRequest = http.createHttp();
+          const headResponse = await headRequest.request(streamUrl, {
+            method: http.RequestMethod.HEAD,
+            connectTimeout: 30000,
+            readTimeout: 30000,
+            expectDataType: http.HttpDataType.STRING
+          });
+          const contentLength = headResponse.header['Content-Length'] as string;
+          if (contentLength) {
+            finalFileSize = parseInt(contentLength, 10);
+          }
+          headRequest.destroy();
+        } catch (error) {
+          Logger.warn(TAG, `HEAD请求获取文件大小失败,将使用分片下载: ${(error as Error).message}`);
+        }
+      }
+      await downloadAudioStationFileChunked(streamUrl, cachePath, finalFileSize);
+      Logger.info(TAG, `AudioStation 文件下载完成: ${cachePath}`);
+    } catch (error) {
+      await FileManager.deleteFile(cachePath);
+      const err = error as Error;
+      Logger.error(TAG, `AudioStation 文件下载失败: ${err.message}`);
+      throw err;
+    }
+  }
+
+  return cachePath;
+}
+
+async function downloadAudioStationFileChunked(streamUrl: string, localPath: string, totalSize: number): Promise<void> {
+  const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+  try {
+    if (totalSize === 0) {
+      await downloadSingleAudioStationChunk(streamUrl, 0, -1, file);
+    } else {
+      let downloadedSize: number = 0;
+      while (downloadedSize < totalSize) {
+        const rangeStart = downloadedSize;
+        const rangeEnd = Math.min(downloadedSize + CHUNK_SIZE - 1, totalSize - 1);
+        await downloadSingleAudioStationChunk(streamUrl, rangeStart, rangeEnd, file);
+        downloadedSize = rangeEnd + 1;
+      }
+    }
+    fileIo.closeSync(file);
+  } finally {
+    // file closed above
+  }
+}
+
+async function downloadSingleAudioStationChunk(
+  streamUrl: string,
+  rangeStart: number,
+  rangeEnd: number,
+  file: fileIo.File
+): Promise<void> {
+  const httpRequest = http.createHttp();
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000,
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+    if (rangeEnd >= 0) {
+      if (!options.header) {
+        options.header = {};
+      }
+      options.header['Range'] = `bytes=${rangeStart}-${rangeEnd}`;
+    }
+    const response = await httpRequest.request(streamUrl, options);
+    if (response.responseCode !== 200 && response.responseCode !== 206) {
+      throw new Error(`AudioStation 下载失败 HTTP ${response.responseCode}`);
+    }
+    if (response.result instanceof ArrayBuffer) {
+      const arrayBuffer = response.result as ArrayBuffer;
+      if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+        throw new Error('AudioStation 下载的文件为空');
+      }
+      await fileIo.write(file.fd, arrayBuffer, {
+        offset: rangeStart,
+        length: arrayBuffer.byteLength
+      });
+    }
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+RemoteCacheManager.registerStrategy(new AudioStationCacheStrategy());
+
+export async function ensureAudioStationFileCached(
+  account: WebDavAccount,
+  songId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  return RemoteCacheManager.ensureCached(RemoteCacheType.AUDIOSTATION, {
+    account,
+    songId,
+    streamUrl,
+    fileSize
+  } as AudioStationCacheOptions);
+}

+ 539 - 0
entry/src/main/ets/common/network/PlexApi.ets

@@ -0,0 +1,539 @@
+import { http } from '@kit.NetworkKit';
+import { PreferencesUtil } from '@pura/harmony-utils';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import { ServerLogUtil } from '../util/ServerLogUtil';
+
+const TAG = 'heanup PlexApi';
+const CLIENT_ID_KEY = 'plex_client_id';
+const PRODUCT_NAME = 'TTMusic';
+const PRODUCT_VERSION = '1.0.0';
+const PLATFORM_NAME = 'HarmonyOS';
+const DEVICE_NAME = 'HarmonyOS';
+
+interface PlexAuthContext {
+  token: string;
+  clientId: string;
+  musicSectionId?: string;
+}
+
+interface PlexContainerInfo {
+  size?: number;
+  totalSize?: number;
+  offset?: number;
+}
+
+class QueryParam {
+  key: string;
+  value: string;
+
+  constructor(key: string, value: string) {
+    this.key = key;
+    this.value = value;
+  }
+}
+
+export interface PlexArtist {
+  id: string;
+  name: string;
+  thumb?: string;
+}
+
+export interface PlexAlbum {
+  id: string;
+  name: string;
+  artist?: string;
+  year?: number;
+  thumb?: string;
+}
+
+export interface PlexSong {
+  id: string;
+  title?: string;
+  artist?: string;
+  album?: string;
+  durationSeconds?: number;
+  size?: number;
+  suffix?: string;
+  bitRate?: number;
+  sampleRate?: number;
+  track?: number;
+  year?: number;
+  partKey?: string;
+  thumb?: string;
+  albumThumb?: string;
+  mimeType?: string;
+}
+
+export interface PlexPlaylist {
+  id: string;
+  title?: string;
+  summary?: string;
+  leafCount?: number;
+  duration?: number;
+  composite?: string;
+}
+
+export interface PlexPagedResponse<T> {
+  items: T[];
+  nextStart: number | null;
+}
+
+export class PlexApi {
+  private authCache: Map<string, PlexAuthContext> = new Map();
+
+  async getArtists(account: WebDavAccount): Promise<PlexArtist[]> {
+    const artists: PlexArtist[] = [];
+    let start = 0;
+    const size = 200;
+    while (true) {
+      const page = await this.getArtistsPage(account, start, size);
+      artists.push(...page.items);
+      if (page.nextStart === null) {
+        break;
+      }
+      start = page.nextStart;
+    }
+    return artists;
+  }
+
+  async getArtistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<PlexPagedResponse<PlexArtist>> {
+    const sectionId = await this.ensureMusicSectionId(account);
+    const params = [
+      new QueryParam('type', '8'),
+      new QueryParam('includeAdvanced', '1'),
+      new QueryParam('includeCollections', '1'),
+      new QueryParam('includeExternalMedia', '1'),
+      new QueryParam('X-Plex-Container-Start', `${start}`),
+      new QueryParam('X-Plex-Container-Size', `${size}`)
+    ];
+    const xml = await this.getXml(account, `/library/sections/${sectionId}/all`, params);
+    const container = this.parseContainer(xml);
+    const entries = this.parseDirectories(xml)
+      .filter(item => item.type === 'artist' && item.ratingKey && item.title)
+      .map(item => {
+        const artist: PlexArtist = {
+          id: item.ratingKey as string,
+          name: item.title as string,
+          thumb: item.thumb
+        };
+        return artist;
+      });
+    const nextStart = this.computeNextStart(container, entries.length, start);
+    return { items: entries, nextStart };
+  }
+
+  async getArtistAlbums(account: WebDavAccount, artistId: string): Promise<PlexAlbum[]> {
+    const params = [new QueryParam('excludeAllLeaves', '1')];
+    const xml = await this.getXml(account, `/library/metadata/${artistId}/children`, params);
+    return this.parseDirectories(xml)
+      .filter(item => item.type === 'album' && item.ratingKey && item.title)
+      .map(item => {
+        const album: PlexAlbum = {
+          id: item.ratingKey as string,
+          name: item.title as string,
+          artist: item.parentTitle,
+          year: item.year ? Number(item.year) : undefined,
+          thumb: item.thumb
+        };
+        return album;
+      });
+  }
+
+  async getAlbumSongs(account: WebDavAccount, albumId: string): Promise<PlexSong[]> {
+    const xml = await this.getXml(account, `/library/metadata/${albumId}/children`, []);
+    return this.parseTracks(xml);
+  }
+
+  async getSongsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<PlexPagedResponse<PlexSong>> {
+    const sectionId = await this.ensureMusicSectionId(account);
+    const params = [
+      new QueryParam('type', '10'),
+      new QueryParam('sort', 'addedAt:desc'),
+      new QueryParam('includeCollections', '1'),
+      new QueryParam('includeExternalMedia', '1'),
+      new QueryParam('X-Plex-Container-Start', `${start}`),
+      new QueryParam('X-Plex-Container-Size', `${size}`)
+    ];
+    const xml = await this.getXml(account, `/library/sections/${sectionId}/all`, params);
+    const container = this.parseContainer(xml);
+    const entries = this.parseTracks(xml);
+    const nextStart = this.computeNextStart(container, entries.length, start);
+    return { items: entries, nextStart };
+  }
+
+  async getAlbumsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<PlexPagedResponse<PlexAlbum>> {
+    const sectionId = await this.ensureMusicSectionId(account);
+    const params = [
+      new QueryParam('type', '9'),
+      new QueryParam('sort', 'titleSort:asc'),
+      new QueryParam('X-Plex-Container-Start', `${start}`),
+      new QueryParam('X-Plex-Container-Size', `${size}`)
+    ];
+    const xml = await this.getXml(account, `/library/sections/${sectionId}/all`, params);
+    const container = this.parseContainer(xml);
+    const entries = this.parseDirectories(xml)
+      .filter(item => item.type === 'album' && item.ratingKey && item.title)
+      .map(item => {
+        const album: PlexAlbum = {
+          id: item.ratingKey as string,
+          name: item.title as string,
+          artist: item.parentTitle,
+          year: item.year ? Number(item.year) : undefined,
+          thumb: item.thumb
+        };
+        return album;
+      });
+    const nextStart = this.computeNextStart(container, entries.length, start);
+    return { items: entries, nextStart };
+  }
+
+  async getPlaylistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<PlexPagedResponse<PlexPlaylist>> {
+    const params = [
+      new QueryParam('playlistType', 'audio'),
+      new QueryParam('X-Plex-Container-Start', `${start}`),
+      new QueryParam('X-Plex-Container-Size', `${size}`)
+    ];
+    const xml = await this.getXml(account, '/playlists', params);
+    const container = this.parseContainer(xml);
+    const entries = this.parsePlaylists(xml);
+    const nextStart = this.computeNextStart(container, entries.length, start);
+    return { items: entries, nextStart };
+  }
+
+  async getPlaylistSongsPage(account: WebDavAccount, playlistId: string, start: number = 0, size: number = 200): Promise<PlexPagedResponse<PlexSong>> {
+    const params = [
+      new QueryParam('X-Plex-Container-Start', `${start}`),
+      new QueryParam('X-Plex-Container-Size', `${size}`)
+    ];
+    const xml = await this.getXml(account, `/playlists/${playlistId}/items`, params);
+    const container = this.parseContainer(xml);
+    const entries = this.parseTracks(xml);
+    const nextStart = this.computeNextStart(container, entries.length, start);
+    return { items: entries, nextStart };
+  }
+
+  async searchSongs(account: WebDavAccount, keyword: string, start: number = 0, size: number = 200): Promise<PlexPagedResponse<PlexSong>> {
+    const query = keyword.trim();
+    if (query.length === 0) {
+      return { items: [], nextStart: null };
+    }
+    const params = [
+      new QueryParam('query', query),
+      new QueryParam('type', '10'),
+      new QueryParam('X-Plex-Container-Start', `${start}`),
+      new QueryParam('X-Plex-Container-Size', `${size}`)
+    ];
+    const xml = await this.getXml(account, '/search', params);
+    const container = this.parseContainer(xml);
+    const entries = this.parseTracks(xml);
+    const nextStart = this.computeNextStart(container, entries.length, start);
+    return { items: entries, nextStart };
+  }
+
+  async buildStreamUrl(account: WebDavAccount, songId: string, partKey?: string): Promise<string> {
+    const auth = await this.ensureAuth(account);
+    let resolvedPartKey = partKey;
+    if (!resolvedPartKey) {
+      const xml = await this.getXml(account, `/library/metadata/${songId}`, []);
+      const tracks = this.parseTracks(xml);
+      resolvedPartKey = tracks[0]?.partKey;
+    }
+    if (!resolvedPartKey) {
+      throw new Error('Plex 未找到可用的播放地址');
+    }
+    const baseUrl = this.buildBaseUrl(account);
+    const tokenQuery = `X-Plex-Token=${encodeURIComponent(auth.token)}`;
+    const joiner = resolvedPartKey.includes('?') ? '&' : '?';
+    return `${baseUrl}${resolvedPartKey}${joiner}${tokenQuery}`;
+  }
+
+  buildImageUrl(account: WebDavAccount, path?: string): string | undefined {
+    if (!path) {
+      return undefined;
+    }
+    if (path.startsWith('http://') || path.startsWith('https://')) {
+      return path;
+    }
+    const auth = this.authCache.get(this.buildCacheKey(account));
+    if (!auth?.token) {
+      return undefined;
+    }
+    const baseUrl = this.buildBaseUrl(account);
+    const joiner = path.includes('?') ? '&' : '?';
+    return `${baseUrl}${path}${joiner}X-Plex-Token=${encodeURIComponent(auth.token)}`;
+  }
+
+  private async ensureMusicSectionId(account: WebDavAccount): Promise<string> {
+    const auth = await this.ensureAuth(account);
+    if (auth.musicSectionId) {
+      return auth.musicSectionId;
+    }
+    const xml = await this.getXml(account, '/library/sections', []);
+    const directories = this.parseDirectories(xml);
+    const candidate = directories.find(item => item.type === 'artist') ?? directories[0];
+    if (!candidate?.key) {
+      throw new Error('未找到 Plex 音乐资料库');
+    }
+    auth.musicSectionId = candidate.key;
+    this.authCache.set(this.buildCacheKey(account), auth);
+    return candidate.key;
+  }
+
+  private async ensureAuth(account: WebDavAccount): Promise<PlexAuthContext> {
+    const key = this.buildCacheKey(account);
+    const cached = this.authCache.get(key);
+    if (cached?.token) {
+      return cached;
+    }
+    const token = await this.login(account);
+    const auth: PlexAuthContext = {
+      token,
+      clientId: this.getClientId()
+    };
+    this.authCache.set(key, auth);
+    return auth;
+  }
+
+  private async login(account: WebDavAccount): Promise<string> {
+    if (!account.account || !account.password) {
+      throw new Error('Plex 账号或密码为空');
+    }
+    const httpRequest = http.createHttp();
+    const headers = this.buildClientHeaders();
+    headers.Accept = 'application/xml';
+    headers['Content-Type'] = 'application/json';
+    const response = await httpRequest.request('https://plex.tv/api/v2/users/signin', {
+      method: http.RequestMethod.POST,
+      header: headers,
+      extraData: JSON.stringify({
+        login: account.account,
+        password: account.password,
+        rememberMe: true
+      }),
+      connectTimeout: 10000,
+      readTimeout: 15000,
+      expectDataType: http.HttpDataType.STRING
+    });
+    if (response.responseCode < 200 || response.responseCode >= 300) {
+      httpRequest.destroy();
+      throw new Error(`Plex 登录失败: HTTP ${response.responseCode}`);
+    }
+    httpRequest.destroy();
+    const xmlText = typeof response.result === 'string' ? response.result : '';
+    const tokenMatch = xmlText.match(/authToken="([^"]+)"/);
+    if (!tokenMatch || !tokenMatch[1]) {
+      throw new Error('Plex 登录失败,未获取到 token');
+    }
+    void ServerLogUtil.info(TAG, 'Plex 登录成功');
+    return tokenMatch[1];
+  }
+
+  private async getXml(account: WebDavAccount, path: string, params: QueryParam[]): Promise<string> {
+    const auth = await this.ensureAuth(account);
+    const baseUrl = this.buildBaseUrl(account);
+    const query = this.buildQuery(params, auth.token);
+    const url = `${baseUrl}${path}${query ? `?${query}` : ''}`;
+    const httpRequest = http.createHttp();
+    const headers = this.buildClientHeaders(auth.token);
+    headers.Accept = 'application/xml';
+    const response = await httpRequest.request(url, {
+      method: http.RequestMethod.GET,
+      header: headers,
+      connectTimeout: 10000,
+      readTimeout: 15000,
+      expectDataType: http.HttpDataType.STRING
+    });
+    if (response.responseCode < 200 || response.responseCode >= 300) {
+      httpRequest.destroy();
+      throw new Error(`Plex 请求失败: HTTP ${response.responseCode}`);
+    }
+    httpRequest.destroy();
+    if (typeof response.result === 'string') {
+      return response.result;
+    }
+    return '';
+  }
+
+  private buildBaseUrl(account: WebDavAccount): string {
+    const scheme = account.enableHttps ? 'https' : 'http';
+    const port = account.port || 32400;
+    return `${scheme}://${account.host}:${port}`;
+  }
+
+  private buildCacheKey(account: WebDavAccount): string {
+    return `${account.id ?? account.host}_${account.account}`;
+  }
+
+  private buildClientHeaders(token?: string): Record<string, string> {
+    const headers: Record<string, string> = {
+      'X-Plex-Client-Identifier': this.getClientId(),
+      'X-Plex-Platform': PLATFORM_NAME,
+      'X-Plex-Provides': 'player',
+      'X-Plex-Product': PRODUCT_NAME,
+      'X-Plex-Version': PRODUCT_VERSION,
+      'X-Plex-Device': DEVICE_NAME,
+      'X-Plex-Device-Name': DEVICE_NAME
+    };
+    if (token) {
+      headers['X-Plex-Token'] = token;
+    }
+    return headers;
+  }
+
+  private getClientId(): string {
+    const cached = PreferencesUtil.getStringSync(CLIENT_ID_KEY, '');
+    if (cached && cached.length > 0) {
+      return cached;
+    }
+    const id = `ttmusic_${Date.now().toString(36)}_${Math.floor(Math.random() * 100000)}`;
+    PreferencesUtil.putSync(CLIENT_ID_KEY, id);
+    return id;
+  }
+
+  private buildQuery(params: QueryParam[], token: string): string {
+    const allParams = [...params, new QueryParam('X-Plex-Token', token)];
+    return allParams
+      .filter(param => param.key && param.value !== undefined && param.value !== null)
+      .map(param => `${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`)
+      .join('&');
+  }
+
+  private parseContainer(xmlText: string): PlexContainerInfo {
+    const containerMatch = xmlText.match(/<MediaContainer\b([^>]*)>/);
+    if (!containerMatch) {
+      return {};
+    }
+    const attrs = this.parseAttributes(containerMatch[1]);
+    return {
+      size: attrs.size ? Number(attrs.size) : undefined,
+      totalSize: attrs.totalSize ? Number(attrs.totalSize) : undefined,
+      offset: attrs.offset ? Number(attrs.offset) : undefined
+    };
+  }
+
+  private parseDirectories(xmlText: string): Array<Record<string, string>> {
+    const results: Array<Record<string, string>> = [];
+    const selfClosing = /<Directory\b([^>]*)\/>/g;
+    let match = selfClosing.exec(xmlText);
+    while (match) {
+      results.push(this.parseAttributes(match[1]));
+      match = selfClosing.exec(xmlText);
+    }
+    const fullTag = /<Directory\b([^>]*)>([\s\S]*?)<\/Directory>/g;
+    let fullMatch = fullTag.exec(xmlText);
+    while (fullMatch) {
+      results.push(this.parseAttributes(fullMatch[1]));
+      fullMatch = fullTag.exec(xmlText);
+    }
+    return results;
+  }
+
+  private parseTracks(xmlText: string): PlexSong[] {
+    const tracks: PlexSong[] = [];
+    const trackTag = /<Track\b([^>]*)>([\s\S]*?)<\/Track>/g;
+    let match = trackTag.exec(xmlText);
+    while (match) {
+      const attrs = this.parseAttributes(match[1]);
+      const inner = match[2] ?? '';
+      const partAttrs = this.extractTagAttributes(inner, 'Part');
+      const mediaAttrs = this.extractTagAttributes(inner, 'Media');
+      const durationMs = attrs.duration ? Number(attrs.duration) : (mediaAttrs.duration ? Number(mediaAttrs.duration) : undefined);
+      const bitRate = mediaAttrs.bitrate ? Number(mediaAttrs.bitrate) : undefined;
+      const suffix = mediaAttrs.container ?? partAttrs.container;
+      const size = partAttrs.size ? Number(partAttrs.size) : undefined;
+      const track: PlexSong = {
+        id: attrs.ratingKey ?? attrs.key ?? '',
+        title: attrs.title,
+        artist: attrs.grandparentTitle,
+        album: attrs.parentTitle,
+        durationSeconds: durationMs ? Math.round(durationMs / 1000) : undefined,
+        size,
+        suffix,
+        bitRate,
+        track: attrs.index ? Number(attrs.index) : undefined,
+        year: attrs.parentYear ? Number(attrs.parentYear) : undefined,
+        partKey: partAttrs.key,
+        thumb: attrs.thumb,
+        albumThumb: attrs.parentThumb,
+        mimeType: suffix ? `audio/${suffix}` : undefined
+      };
+      if (track.id) {
+        tracks.push(track);
+      }
+      match = trackTag.exec(xmlText);
+    }
+    return tracks;
+  }
+
+  private parsePlaylists(xmlText: string): PlexPlaylist[] {
+    const results: PlexPlaylist[] = [];
+    const playlistTag = /<Playlist\b([^>]*)\/>/g;
+    let match = playlistTag.exec(xmlText);
+    while (match) {
+      const attrs = this.parseAttributes(match[1]);
+      if (attrs.ratingKey) {
+        const playlist: PlexPlaylist = {
+          id: attrs.ratingKey,
+          title: attrs.title,
+          summary: attrs.summary,
+          leafCount: attrs.leafCount ? Number(attrs.leafCount) : undefined,
+          duration: attrs.duration ? Number(attrs.duration) : undefined,
+          composite: attrs.composite
+        };
+        results.push(playlist);
+      }
+      match = playlistTag.exec(xmlText);
+    }
+    const fullTag = /<Playlist\b([^>]*)>([\s\S]*?)<\/Playlist>/g;
+    let fullMatch = fullTag.exec(xmlText);
+    while (fullMatch) {
+      const attrs = this.parseAttributes(fullMatch[1]);
+      if (attrs.ratingKey) {
+        const playlist: PlexPlaylist = {
+          id: attrs.ratingKey,
+          title: attrs.title,
+          summary: attrs.summary,
+          leafCount: attrs.leafCount ? Number(attrs.leafCount) : undefined,
+          duration: attrs.duration ? Number(attrs.duration) : undefined,
+          composite: attrs.composite
+        };
+        results.push(playlist);
+      }
+      fullMatch = fullTag.exec(xmlText);
+    }
+    return results;
+  }
+
+  private extractTagAttributes(xmlText: string, tagName: string): Record<string, string> {
+    const tagMatch = xmlText.match(new RegExp(`<${tagName}\\b([^>]*)\\/?>`));
+    if (!tagMatch) {
+      return {};
+    }
+    return this.parseAttributes(tagMatch[1]);
+  }
+
+  private parseAttributes(raw: string): Record<string, string> {
+    const attrs: Record<string, string> = {};
+    if (!raw) {
+      return attrs;
+    }
+    const attrRegex = /([\w:-]+)="([^"]*)"/g;
+    let match = attrRegex.exec(raw);
+    while (match) {
+      attrs[match[1]] = match[2];
+      match = attrRegex.exec(raw);
+    }
+    return attrs;
+  }
+
+  private computeNextStart(container: PlexContainerInfo, receivedCount: number, start: number): number | null {
+    const total = container.totalSize ?? container.size;
+    if (total === undefined || total === null) {
+      return receivedCount > 0 ? start + receivedCount : null;
+    }
+    const next = start + receivedCount;
+    return next < total ? next : null;
+  }
+}
+
+export const plexApi = new PlexApi();

+ 167 - 0
entry/src/main/ets/common/network/PlexFileCache.ets

@@ -0,0 +1,167 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+import FileManager from '../util/FileManager';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
+import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
+import { fileIo } from '@kit.CoreFileKit';
+
+const TAG = 'heanup PlexFileCache';
+const CHUNK_SIZE = 4 * 1024 * 1024;
+
+interface PlexCacheOptions extends RemoteCacheRequest {
+  account: WebDavAccount;
+  partKey: string;
+  streamUrl: string;
+  fileSize?: number;
+}
+
+class PlexCacheStrategy implements RemoteCacheStrategy {
+  readonly type: RemoteCacheType = RemoteCacheType.PLEX;
+
+  async ensure(options: RemoteCacheRequest): Promise<string> {
+    const plexOptions = options as PlexCacheOptions;
+    return ensurePlexFileCachedInternal(
+      plexOptions.account,
+      plexOptions.partKey,
+      plexOptions.streamUrl,
+      plexOptions.fileSize
+    );
+  }
+}
+
+async function ensurePlexFileCachedInternal(
+  account: WebDavAccount,
+  partKey: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  const normalizedRelative = normalizeCacheRelativePath(partKey);
+  const pathInfo = await resolveCacheFilePath(
+    RemoteCacheType.PLEX,
+    account.id?.toString(),
+    normalizedRelative
+  );
+  const cachePath = pathInfo.cachePath;
+
+  let exists = await FileManager.isExist(cachePath);
+  if (exists) {
+    const size = await FileManager.getFileSize(cachePath);
+    if (size <= 0) {
+      await FileManager.deleteFile(cachePath);
+      exists = false;
+    } else {
+      Logger.info(TAG, `Plex 文件已缓存: ${cachePath}, size=${size}`);
+      return cachePath;
+    }
+  }
+
+  if (!exists) {
+    try {
+      Logger.info(TAG, `开始下载 Plex 文件: partKey=${partKey}, 到 ${cachePath}`);
+      let finalFileSize = fileSize || 0;
+      if (!finalFileSize || finalFileSize <= 0) {
+        try {
+          const headRequest = http.createHttp();
+          const headResponse = await headRequest.request(streamUrl, {
+            method: http.RequestMethod.HEAD,
+            connectTimeout: 30000,
+            readTimeout: 30000,
+            expectDataType: http.HttpDataType.STRING
+          });
+          const contentLength = headResponse.header['Content-Length'] as string;
+          if (contentLength) {
+            finalFileSize = parseInt(contentLength, 10);
+          }
+          headRequest.destroy();
+        } catch (error) {
+          Logger.warn(TAG, `HEAD请求获取文件大小失败,将使用分片下载: ${(error as Error).message}`);
+        }
+      }
+      await downloadPlexFileChunked(streamUrl, cachePath, finalFileSize);
+      Logger.info(TAG, `Plex 文件下载完成: ${cachePath}`);
+    } catch (error) {
+      await FileManager.deleteFile(cachePath);
+      const err = error as Error;
+      Logger.error(TAG, `Plex 文件下载失败: ${err.message}`);
+      throw err;
+    }
+  }
+
+  return cachePath;
+}
+
+async function downloadPlexFileChunked(streamUrl: string, localPath: string, totalSize: number): Promise<void> {
+  const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+  try {
+    if (totalSize === 0) {
+      await downloadSinglePlexChunk(streamUrl, 0, -1, file);
+    } else {
+      let downloadedSize: number = 0;
+      while (downloadedSize < totalSize) {
+        const rangeStart = downloadedSize;
+        const rangeEnd = Math.min(downloadedSize + CHUNK_SIZE - 1, totalSize - 1);
+        await downloadSinglePlexChunk(streamUrl, rangeStart, rangeEnd, file);
+        downloadedSize = rangeEnd + 1;
+      }
+    }
+    fileIo.closeSync(file);
+  } finally {
+    // file closed above
+  }
+}
+
+async function downloadSinglePlexChunk(
+  streamUrl: string,
+  rangeStart: number,
+  rangeEnd: number,
+  file: fileIo.File
+): Promise<void> {
+  const httpRequest = http.createHttp();
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000,
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+    if (rangeEnd >= 0) {
+      if (!options.header) {
+        options.header = {};
+      }
+      options.header['Range'] = `bytes=${rangeStart}-${rangeEnd}`;
+    }
+    const response = await httpRequest.request(streamUrl, options);
+    if (response.responseCode !== 200 && response.responseCode !== 206) {
+      throw new Error(`Plex 下载失败 HTTP ${response.responseCode}`);
+    }
+    if (response.result instanceof ArrayBuffer) {
+      const arrayBuffer = response.result as ArrayBuffer;
+      if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+        throw new Error('Plex 下载的文件为空');
+      }
+      await fileIo.write(file.fd, arrayBuffer, {
+        offset: rangeStart,
+        length: arrayBuffer.byteLength
+      });
+    }
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+RemoteCacheManager.registerStrategy(new PlexCacheStrategy());
+
+export async function ensurePlexFileCached(
+  account: WebDavAccount,
+  partKey: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  return RemoteCacheManager.ensureCached(RemoteCacheType.PLEX, {
+    account,
+    partKey,
+    streamUrl,
+    fileSize
+  } as PlexCacheOptions);
+}

+ 167 - 0
entry/src/main/ets/common/network/SubsonicFileCache.ets

@@ -0,0 +1,167 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+import FileManager from '../util/FileManager';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
+import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
+import { fileIo } from '@kit.CoreFileKit';
+
+const TAG = 'heanup SubsonicFileCache';
+const CHUNK_SIZE = 4 * 1024 * 1024;
+
+interface SubsonicCacheOptions extends RemoteCacheRequest {
+  account: WebDavAccount;
+  songId: string;
+  streamUrl: string;
+  fileSize?: number;
+}
+
+class SubsonicCacheStrategy implements RemoteCacheStrategy {
+  readonly type: RemoteCacheType = RemoteCacheType.SUBSONIC;
+
+  async ensure(options: RemoteCacheRequest): Promise<string> {
+    const subsonicOptions = options as SubsonicCacheOptions;
+    return ensureSubsonicFileCachedInternal(
+      subsonicOptions.account,
+      subsonicOptions.songId,
+      subsonicOptions.streamUrl,
+      subsonicOptions.fileSize
+    );
+  }
+}
+
+async function ensureSubsonicFileCachedInternal(
+  account: WebDavAccount,
+  songId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  const normalizedRelative = normalizeCacheRelativePath(songId);
+  const pathInfo = await resolveCacheFilePath(
+    RemoteCacheType.SUBSONIC,
+    account.id?.toString(),
+    normalizedRelative
+  );
+  const cachePath = pathInfo.cachePath;
+
+  let exists = await FileManager.isExist(cachePath);
+  if (exists) {
+    const size = await FileManager.getFileSize(cachePath);
+    if (size <= 0) {
+      await FileManager.deleteFile(cachePath);
+      exists = false;
+    } else {
+      Logger.info(TAG, `Subsonic 文件已缓存: ${cachePath}, size=${size}`);
+      return cachePath;
+    }
+  }
+
+  if (!exists) {
+    try {
+      Logger.info(TAG, `开始下载 Subsonic 文件: songId=${songId}, 到 ${cachePath}`);
+      let finalFileSize = fileSize || 0;
+      if (!finalFileSize || finalFileSize <= 0) {
+        try {
+          const headRequest = http.createHttp();
+          const headResponse = await headRequest.request(streamUrl, {
+            method: http.RequestMethod.HEAD,
+            connectTimeout: 30000,
+            readTimeout: 30000,
+            expectDataType: http.HttpDataType.STRING
+          });
+          const contentLength = headResponse.header['Content-Length'] as string;
+          if (contentLength) {
+            finalFileSize = parseInt(contentLength, 10);
+          }
+          headRequest.destroy();
+        } catch (error) {
+          Logger.warn(TAG, `HEAD请求获取文件大小失败,将使用分片下载: ${(error as Error).message}`);
+        }
+      }
+      await downloadSubsonicFileChunked(streamUrl, cachePath, finalFileSize);
+      Logger.info(TAG, `Subsonic 文件下载完成: ${cachePath}`);
+    } catch (error) {
+      await FileManager.deleteFile(cachePath);
+      const err = error as Error;
+      Logger.error(TAG, `Subsonic 文件下载失败: ${err.message}`);
+      throw err;
+    }
+  }
+
+  return cachePath;
+}
+
+async function downloadSubsonicFileChunked(streamUrl: string, localPath: string, totalSize: number): Promise<void> {
+  const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+  try {
+    if (totalSize === 0) {
+      await downloadSingleSubsonicChunk(streamUrl, 0, -1, file);
+    } else {
+      let downloadedSize: number = 0;
+      while (downloadedSize < totalSize) {
+        const rangeStart = downloadedSize;
+        const rangeEnd = Math.min(downloadedSize + CHUNK_SIZE - 1, totalSize - 1);
+        await downloadSingleSubsonicChunk(streamUrl, rangeStart, rangeEnd, file);
+        downloadedSize = rangeEnd + 1;
+      }
+    }
+    fileIo.closeSync(file);
+  } finally {
+    // file closed above
+  }
+}
+
+async function downloadSingleSubsonicChunk(
+  streamUrl: string,
+  rangeStart: number,
+  rangeEnd: number,
+  file: fileIo.File
+): Promise<void> {
+  const httpRequest = http.createHttp();
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000,
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+    if (rangeEnd >= 0) {
+      if (!options.header) {
+        options.header = {};
+      }
+      options.header['Range'] = `bytes=${rangeStart}-${rangeEnd}`;
+    }
+    const response = await httpRequest.request(streamUrl, options);
+    if (response.responseCode !== 200 && response.responseCode !== 206) {
+      throw new Error(`Subsonic 下载失败 HTTP ${response.responseCode}`);
+    }
+    if (response.result instanceof ArrayBuffer) {
+      const arrayBuffer = response.result as ArrayBuffer;
+      if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+        throw new Error('Subsonic 下载的文件为空');
+      }
+      await fileIo.write(file.fd, arrayBuffer, {
+        offset: rangeStart,
+        length: arrayBuffer.byteLength
+      });
+    }
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+RemoteCacheManager.registerStrategy(new SubsonicCacheStrategy());
+
+export async function ensureSubsonicFileCached(
+  account: WebDavAccount,
+  songId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  return RemoteCacheManager.ensureCached(RemoteCacheType.SUBSONIC, {
+    account,
+    songId,
+    streamUrl,
+    fileSize
+  } as SubsonicCacheOptions);
+}