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; } interface AudioStationResponse { 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 { 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; 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 = new Map(); async getArtists(account: WebDavAccount, offset: number = 0, limit: number = 200): Promise { 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>(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> { 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>(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 = { items, nextStart }; return result; } async getAlbums(account: WebDavAccount, artistName?: string, offset: number = 0, limit: number = 200): Promise { 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>(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> { 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>(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 = { items, nextStart }; return result; } async getAlbumSongs( account: WebDavAccount, albumName: string, albumArtist?: string, offset: number = 0, limit: number = 500 ): Promise { 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>(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 { 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>(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> { 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>(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 = { items, nextStart }; return result; } async searchSongs(account: WebDavAccount, keyword: string, offset: number = 0, limit: number = 200): Promise { 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>(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> { 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>(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 = { items, nextStart }; return result; } async getPlaylistSongsPage( account: WebDavAccount, playlistId: string, offset: number = 0, limit: number = 200 ): Promise> { 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>(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 = { items, nextStart }; return result; } async buildSongCoverUrl(account: WebDavAccount, songId: string | undefined): Promise { 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 { 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 { 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 { 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 { 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>(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(url: string, params: QueryParam[]): Promise { 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(response.result); } private async post(url: string, params: QueryParam[], body?: AudioStationRequestBody): Promise { 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(response.result); } private async postForm(url: string, params: QueryParam[]): Promise { 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(response.result); } private parseResponse(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();