|
|
@@ -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();
|