AudioStationApi.ets 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968
  1. import { http } from '@kit.NetworkKit';
  2. import { PreferencesUtil } from '@pura/harmony-utils';
  3. import { WebDavAccount } from '../../viewmodel/WebDavAccount';
  4. import { ServerLogUtil } from '../util/ServerLogUtil';
  5. const TAG = 'heanup AudioStationApi';
  6. const DEVICE_ID_KEY = 'audiostation_device_id';
  7. const DEVICE_NAME = 'HarmonyOS';
  8. interface AudioStationAuthContext {
  9. sid: string;
  10. did?: string;
  11. }
  12. interface AudioStationError {
  13. code?: number;
  14. errors?: Record<string, string>;
  15. }
  16. interface AudioStationResponse<T> {
  17. success: boolean;
  18. data?: T;
  19. error?: AudioStationError;
  20. }
  21. interface AudioStationSongAudio {
  22. bitrate?: number;
  23. channel?: number;
  24. codec?: string;
  25. container?: string;
  26. duration?: number;
  27. filesize?: number;
  28. frequency?: number;
  29. }
  30. interface AudioStationSongTag {
  31. album?: string;
  32. album_artist?: string;
  33. artist?: string;
  34. genre?: string;
  35. track?: number;
  36. year?: number;
  37. }
  38. interface AudioStationSongEntry {
  39. id?: string;
  40. title?: string;
  41. path?: string;
  42. additional?: AudioStationSongAdditional;
  43. }
  44. interface AudioStationSongAdditional {
  45. song_audio?: AudioStationSongAudio;
  46. song_tag?: AudioStationSongTag;
  47. }
  48. interface AudioStationSongListData {
  49. songs?: AudioStationSongEntry[];
  50. offset?: number;
  51. total?: number;
  52. }
  53. interface AudioStationArtistEntry {
  54. name?: string;
  55. }
  56. interface AudioStationArtistListData {
  57. artists?: AudioStationArtistEntry[];
  58. offset?: number;
  59. total?: number;
  60. }
  61. interface AudioStationAlbumEntry {
  62. name?: string;
  63. album_artist?: string;
  64. display_artist?: string;
  65. year?: number;
  66. }
  67. interface AudioStationAlbumListData {
  68. albums?: AudioStationAlbumEntry[];
  69. offset?: number;
  70. total?: number;
  71. }
  72. interface AudioStationPlaylistEntry {
  73. id?: string;
  74. name?: string;
  75. type?: string;
  76. library?: string;
  77. sharing_status?: string;
  78. path?: string;
  79. }
  80. interface AudioStationPlaylistListData {
  81. playlists?: AudioStationPlaylistEntry[];
  82. offset?: number;
  83. total?: number;
  84. }
  85. interface AudioStationPlaylistSongEntry {
  86. id?: string;
  87. title?: string;
  88. path?: string;
  89. additional?: AudioStationSongAdditional;
  90. }
  91. interface AudioStationPlaylistInfoData {
  92. songs?: AudioStationPlaylistSongEntry[];
  93. offset?: number;
  94. total?: number;
  95. }
  96. interface AudioStationSearchData {
  97. songs?: AudioStationSongEntry[];
  98. songTotal?: number;
  99. artists?: AudioStationArtistEntry[];
  100. artistTotal?: number;
  101. albums?: AudioStationAlbumEntry[];
  102. albumTotal?: number;
  103. }
  104. interface AudioStationPagedResponse<T> {
  105. items: T[];
  106. nextStart: number | null;
  107. total?: number; // 服务端返回的总数
  108. }
  109. interface AudioStationLoginRequestBody {
  110. api: string;
  111. version: string;
  112. method: string;
  113. session: string;
  114. account: string;
  115. passwd: string;
  116. enable_device_token: string;
  117. device_name: string;
  118. device_id: string;
  119. }
  120. interface AudioStationLoginResponseData {
  121. sid?: string;
  122. did?: string;
  123. }
  124. type AudioStationRequestBody = AudioStationLoginRequestBody;
  125. type JsonValue = string | number | boolean | null | Object | Array<JsonValue>;
  126. class QueryParam {
  127. key: string;
  128. value: string;
  129. constructor(key: string, value: string) {
  130. this.key = key;
  131. this.value = value;
  132. }
  133. }
  134. export interface AudioStationArtist {
  135. name: string;
  136. }
  137. export interface AudioStationAlbum {
  138. name: string;
  139. albumArtist?: string;
  140. displayArtist?: string;
  141. year?: number;
  142. }
  143. export interface AudioStationSong {
  144. id: string;
  145. title?: string;
  146. path?: string;
  147. album?: string;
  148. albumArtist?: string;
  149. artist?: string;
  150. durationSeconds?: number;
  151. size?: number;
  152. bitRate?: number;
  153. sampleRate?: number;
  154. codec?: string;
  155. container?: string;
  156. track?: number;
  157. year?: number;
  158. }
  159. export interface AudioStationPlaylist {
  160. id: string;
  161. name: string;
  162. type?: string;
  163. library?: string;
  164. path?: string;
  165. }
  166. export class AudioStationApi {
  167. private authCache: Map<string, AudioStationAuthContext> = new Map();
  168. async getArtists(account: WebDavAccount, offset: number = 0, limit: number = 200): Promise<AudioStationArtist[]> {
  169. const auth = await this.ensureAuth(account);
  170. const url = this.buildWebApiUrl(account, 'AudioStation/artist.cgi');
  171. const artists: AudioStationArtist[] = [];
  172. let currentOffset = offset;
  173. while (true) {
  174. const params = [
  175. new QueryParam('api', 'SYNO.AudioStation.Artist'),
  176. new QueryParam('version', '3'),
  177. new QueryParam('method', 'list'),
  178. new QueryParam('library', 'all'),
  179. new QueryParam('offset', `${currentOffset}`),
  180. new QueryParam('limit', `${limit}`),
  181. new QueryParam('sort_by', 'name'),
  182. new QueryParam('sort_direction', 'ASC'),
  183. new QueryParam('_sid', auth.sid)
  184. ];
  185. const response = await this.get<AudioStationResponse<AudioStationArtistListData>>(url, params);
  186. if (!response.success) {
  187. throw new Error(`AudioStation 获取艺术家失败(code=${response.error?.code ?? 'unknown'})`);
  188. }
  189. const chunk = response.data?.artists ?? [];
  190. const mapped = chunk
  191. .filter(item => item.name)
  192. .map(item => {
  193. const artist: AudioStationArtist = { name: item.name as string };
  194. return artist;
  195. });
  196. artists.push(...mapped);
  197. const total = response.data?.total ?? artists.length;
  198. if (currentOffset + chunk.length >= total || chunk.length === 0) {
  199. break;
  200. }
  201. currentOffset += chunk.length;
  202. }
  203. return artists;
  204. }
  205. async getArtistsPage(account: WebDavAccount, offset: number = 0, limit: number = 200): Promise<AudioStationPagedResponse<AudioStationArtist>> {
  206. const auth = await this.ensureAuth(account);
  207. const url = this.buildWebApiUrl(account, 'AudioStation/artist.cgi');
  208. const params = [
  209. new QueryParam('api', 'SYNO.AudioStation.Artist'),
  210. new QueryParam('version', '3'),
  211. new QueryParam('method', 'list'),
  212. new QueryParam('library', 'all'),
  213. new QueryParam('offset', `${offset}`),
  214. new QueryParam('limit', `${limit}`),
  215. new QueryParam('sort_by', 'name'),
  216. new QueryParam('sort_direction', 'ASC'),
  217. new QueryParam('_sid', auth.sid)
  218. ];
  219. const response = await this.get<AudioStationResponse<AudioStationArtistListData>>(url, params);
  220. if (!response.success) {
  221. throw new Error(`AudioStation 获取艺术家失败(code=${response.error?.code ?? 'unknown'})`);
  222. }
  223. const chunk = response.data?.artists ?? [];
  224. const items = chunk
  225. .filter(item => item.name)
  226. .map(item => {
  227. const artist: AudioStationArtist = { name: item.name as string };
  228. return artist;
  229. });
  230. const total = response.data?.total ?? items.length;
  231. const nextStart = offset + items.length < total ? offset + items.length : null;
  232. const result: AudioStationPagedResponse<AudioStationArtist> = {
  233. items,
  234. nextStart
  235. };
  236. return result;
  237. }
  238. async getAlbums(account: WebDavAccount, artistName?: string, offset: number = 0, limit: number = 200): Promise<AudioStationAlbum[]> {
  239. const auth = await this.ensureAuth(account);
  240. const url = this.buildWebApiUrl(account, 'AudioStation/album.cgi');
  241. const albums: AudioStationAlbum[] = [];
  242. let currentOffset = offset;
  243. while (true) {
  244. const params = [
  245. new QueryParam('api', 'SYNO.AudioStation.Album'),
  246. new QueryParam('version', '3'),
  247. new QueryParam('method', 'list'),
  248. new QueryParam('library', 'all'),
  249. new QueryParam('offset', `${currentOffset}`),
  250. new QueryParam('limit', `${limit}`),
  251. new QueryParam('sort_by', 'name'),
  252. new QueryParam('sort_direction', 'ASC'),
  253. new QueryParam('_sid', auth.sid)
  254. ];
  255. if (artistName) {
  256. params.push(new QueryParam('artist', artistName));
  257. }
  258. const response = await this.get<AudioStationResponse<AudioStationAlbumListData>>(url, params);
  259. if (!response.success) {
  260. throw new Error(`AudioStation 获取专辑失败(code=${response.error?.code ?? 'unknown'})`);
  261. }
  262. const chunk = response.data?.albums ?? [];
  263. const mapped = chunk
  264. .filter(item => item.name)
  265. .map(item => {
  266. const album: AudioStationAlbum = {
  267. name: item.name as string,
  268. albumArtist: item.album_artist,
  269. displayArtist: item.display_artist,
  270. year: item.year
  271. };
  272. return album;
  273. });
  274. albums.push(...mapped);
  275. const total = response.data?.total ?? albums.length;
  276. if (currentOffset + chunk.length >= total || chunk.length === 0) {
  277. break;
  278. }
  279. currentOffset += chunk.length;
  280. }
  281. return albums;
  282. }
  283. async getAlbumsPage(
  284. account: WebDavAccount,
  285. artistName?: string,
  286. offset: number = 0,
  287. limit: number = 200
  288. ): Promise<AudioStationPagedResponse<AudioStationAlbum>> {
  289. const auth = await this.ensureAuth(account);
  290. const url = this.buildWebApiUrl(account, 'AudioStation/album.cgi');
  291. const params = [
  292. new QueryParam('api', 'SYNO.AudioStation.Album'),
  293. new QueryParam('version', '3'),
  294. new QueryParam('method', 'list'),
  295. new QueryParam('library', 'all'),
  296. new QueryParam('offset', `${offset}`),
  297. new QueryParam('limit', `${limit}`),
  298. new QueryParam('sort_by', 'name'),
  299. new QueryParam('sort_direction', 'ASC'),
  300. new QueryParam('_sid', auth.sid)
  301. ];
  302. if (artistName) {
  303. params.push(new QueryParam('artist', artistName));
  304. }
  305. const response = await this.get<AudioStationResponse<AudioStationAlbumListData>>(url, params);
  306. if (!response.success) {
  307. throw new Error(`AudioStation 获取专辑失败(code=${response.error?.code ?? 'unknown'})`);
  308. }
  309. const chunk = response.data?.albums ?? [];
  310. const items = chunk
  311. .filter(item => item.name)
  312. .map(item => {
  313. const album: AudioStationAlbum = {
  314. name: item.name as string,
  315. albumArtist: item.album_artist,
  316. displayArtist: item.display_artist,
  317. year: item.year
  318. };
  319. return album;
  320. });
  321. const total = response.data?.total ?? items.length;
  322. const nextStart = offset + items.length < total ? offset + items.length : null;
  323. const result: AudioStationPagedResponse<AudioStationAlbum> = {
  324. items,
  325. nextStart
  326. };
  327. return result;
  328. }
  329. async getAlbumSongs(
  330. account: WebDavAccount,
  331. albumName: string,
  332. albumArtist?: string,
  333. offset: number = 0,
  334. limit: number = 500
  335. ): Promise<AudioStationSong[]> {
  336. const auth = await this.ensureAuth(account);
  337. const url = this.buildWebApiUrl(account, 'AudioStation/song.cgi');
  338. const songs: AudioStationSong[] = [];
  339. let currentOffset = offset;
  340. while (true) {
  341. const params = [
  342. new QueryParam('api', 'SYNO.AudioStation.Song'),
  343. new QueryParam('version', '3'),
  344. new QueryParam('method', 'list'),
  345. new QueryParam('library', 'all'),
  346. new QueryParam('offset', `${currentOffset}`),
  347. new QueryParam('limit', `${limit}`),
  348. new QueryParam('sort_by', 'title'),
  349. new QueryParam('sort_direction', 'ASC'),
  350. new QueryParam('additional', 'song_tag,song_audio'),
  351. new QueryParam('_sid', auth.sid),
  352. new QueryParam('album', albumName)
  353. ];
  354. if (albumArtist) {
  355. params.push(new QueryParam('album_artist', albumArtist));
  356. }
  357. const response = await this.postForm<AudioStationResponse<AudioStationSongListData>>(url, params);
  358. if (!response.success) {
  359. throw new Error(`AudioStation 获取歌曲失败(code=${response.error?.code ?? 'unknown'})`);
  360. }
  361. const chunk = response.data?.songs ?? [];
  362. songs.push(...chunk
  363. .filter(item => item.id)
  364. .map(item => {
  365. const audio = item.additional?.song_audio;
  366. const tag = item.additional?.song_tag;
  367. const duration = audio?.duration;
  368. const song: AudioStationSong = {
  369. id: item.id as string,
  370. title: item.title,
  371. path: item.path,
  372. album: tag?.album,
  373. albumArtist: tag?.album_artist,
  374. artist: tag?.artist,
  375. durationSeconds: duration ? Math.round(duration) : undefined,
  376. size: audio?.filesize,
  377. bitRate: audio?.bitrate,
  378. sampleRate: audio?.frequency,
  379. codec: audio?.codec,
  380. container: audio?.container,
  381. track: tag?.track,
  382. year: tag?.year
  383. };
  384. return song;
  385. }));
  386. const total = response.data?.total ?? songs.length;
  387. if (currentOffset + chunk.length >= total || chunk.length === 0) {
  388. break;
  389. }
  390. currentOffset += chunk.length;
  391. }
  392. return songs;
  393. }
  394. async getSongs(account: WebDavAccount, offset: number = 0, limit: number = 500): Promise<AudioStationSong[]> {
  395. const auth = await this.ensureAuth(account);
  396. const url = this.buildWebApiUrl(account, 'AudioStation/song.cgi');
  397. const songs: AudioStationSong[] = [];
  398. let currentOffset = offset;
  399. while (true) {
  400. const params = [
  401. new QueryParam('api', 'SYNO.AudioStation.Song'),
  402. new QueryParam('version', '3'),
  403. new QueryParam('method', 'list'),
  404. new QueryParam('library', 'all'),
  405. new QueryParam('offset', `${currentOffset}`),
  406. new QueryParam('limit', `${limit}`),
  407. new QueryParam('sort_by', 'title'),
  408. new QueryParam('sort_direction', 'ASC'),
  409. new QueryParam('additional', 'song_tag,song_audio'),
  410. new QueryParam('_sid', auth.sid)
  411. ];
  412. const response = await this.postForm<AudioStationResponse<AudioStationSongListData>>(url, params);
  413. if (!response.success) {
  414. throw new Error(`AudioStation 获取歌曲失败(code=${response.error?.code ?? 'unknown'})`);
  415. }
  416. const chunk = response.data?.songs ?? [];
  417. songs.push(...chunk
  418. .filter(item => item.id)
  419. .map(item => {
  420. const audio = item.additional?.song_audio;
  421. const tag = item.additional?.song_tag;
  422. const duration = audio?.duration;
  423. const song: AudioStationSong = {
  424. id: item.id as string,
  425. title: item.title,
  426. path: item.path,
  427. album: tag?.album,
  428. albumArtist: tag?.album_artist,
  429. artist: tag?.artist,
  430. durationSeconds: duration ? Math.round(duration) : undefined,
  431. size: audio?.filesize,
  432. bitRate: audio?.bitrate,
  433. sampleRate: audio?.frequency,
  434. codec: audio?.codec,
  435. container: audio?.container,
  436. track: tag?.track,
  437. year: tag?.year
  438. };
  439. return song;
  440. }));
  441. const total = response.data?.total ?? songs.length;
  442. if (currentOffset + chunk.length >= total || chunk.length === 0) {
  443. break;
  444. }
  445. currentOffset += chunk.length;
  446. }
  447. return songs;
  448. }
  449. async getSongsPage(account: WebDavAccount, offset: number = 0, limit: number = 500): Promise<AudioStationPagedResponse<AudioStationSong>> {
  450. const auth = await this.ensureAuth(account);
  451. const url = this.buildWebApiUrl(account, 'AudioStation/song.cgi');
  452. const params = [
  453. new QueryParam('api', 'SYNO.AudioStation.Song'),
  454. new QueryParam('version', '3'),
  455. new QueryParam('method', 'list'),
  456. new QueryParam('library', 'all'),
  457. new QueryParam('offset', `${offset}`),
  458. new QueryParam('limit', `${limit}`),
  459. new QueryParam('sort_by', 'title'),
  460. new QueryParam('sort_direction', 'ASC'),
  461. new QueryParam('additional', 'song_tag,song_audio'),
  462. new QueryParam('_sid', auth.sid)
  463. ];
  464. const response = await this.postForm<AudioStationResponse<AudioStationSongListData>>(url, params);
  465. if (!response.success) {
  466. throw new Error(`AudioStation 获取歌曲失败(code=${response.error?.code ?? 'unknown'})`);
  467. }
  468. const chunk = response.data?.songs ?? [];
  469. const items = chunk
  470. .filter(item => item.id)
  471. .map(item => {
  472. const audio = item.additional?.song_audio;
  473. const tag = item.additional?.song_tag;
  474. const duration = audio?.duration;
  475. const song: AudioStationSong = {
  476. id: item.id as string,
  477. title: item.title,
  478. path: item.path,
  479. album: tag?.album,
  480. albumArtist: tag?.album_artist,
  481. artist: tag?.artist,
  482. durationSeconds: duration ? Math.round(duration) : undefined,
  483. size: audio?.filesize,
  484. bitRate: audio?.bitrate,
  485. sampleRate: audio?.frequency,
  486. codec: audio?.codec,
  487. container: audio?.container,
  488. track: tag?.track,
  489. year: tag?.year
  490. };
  491. return song;
  492. });
  493. const total = response.data?.total ?? items.length;
  494. const nextStart = offset + items.length < total ? offset + items.length : null;
  495. const result: AudioStationPagedResponse<AudioStationSong> = {
  496. items,
  497. nextStart,
  498. total
  499. };
  500. return result;
  501. }
  502. async searchSongs(account: WebDavAccount, keyword: string, offset: number = 0, limit: number = 200): Promise<AudioStationSong[]> {
  503. const trimmed = keyword.trim();
  504. if (trimmed.length === 0) {
  505. return [];
  506. }
  507. const auth = await this.ensureAuth(account);
  508. const url = this.buildWebApiUrl(account, 'AudioStation/search.cgi');
  509. const params = [
  510. new QueryParam('api', 'SYNO.AudioStation.Search'),
  511. new QueryParam('version', '1'),
  512. new QueryParam('method', 'list'),
  513. new QueryParam('library', 'all'),
  514. new QueryParam('offset', `${offset}`),
  515. new QueryParam('limit', `${limit}`),
  516. new QueryParam('keyword', trimmed),
  517. new QueryParam('sort_by', 'title'),
  518. new QueryParam('sort_direction', 'ASC'),
  519. new QueryParam('additional', 'song_tag,song_audio'),
  520. new QueryParam('_sid', auth.sid)
  521. ];
  522. const response = await this.get<AudioStationResponse<AudioStationSearchData>>(url, params);
  523. if (!response.success) {
  524. throw new Error(`AudioStation 搜索失败(code=${response.error?.code ?? 'unknown'})`);
  525. }
  526. const chunk = response.data?.songs ?? [];
  527. const songs: AudioStationSong[] = [];
  528. for (let i = 0; i < chunk.length; i++) {
  529. const item = chunk[i];
  530. if (!item.id) {
  531. continue;
  532. }
  533. const audio = item.additional?.song_audio;
  534. const tag = item.additional?.song_tag;
  535. const duration = audio?.duration;
  536. const song: AudioStationSong = {
  537. id: item.id as string,
  538. title: item.title,
  539. path: item.path,
  540. album: tag?.album,
  541. albumArtist: tag?.album_artist,
  542. artist: tag?.artist,
  543. durationSeconds: duration ? Math.round(duration) : undefined,
  544. size: audio?.filesize,
  545. bitRate: audio?.bitrate,
  546. sampleRate: audio?.frequency,
  547. codec: audio?.codec,
  548. container: audio?.container,
  549. track: tag?.track,
  550. year: tag?.year
  551. };
  552. songs.push(song);
  553. }
  554. return songs;
  555. }
  556. async getPlaylistsPage(account: WebDavAccount, offset: number = 0, limit: number = 200): Promise<AudioStationPagedResponse<AudioStationPlaylist>> {
  557. const auth = await this.ensureAuth(account);
  558. const url = this.buildWebApiUrl(account, 'AudioStation/playlist.cgi');
  559. const params = [
  560. new QueryParam('api', 'SYNO.AudioStation.Playlist'),
  561. new QueryParam('version', '2'),
  562. new QueryParam('method', 'list'),
  563. new QueryParam('library', 'all'),
  564. new QueryParam('offset', `${offset}`),
  565. new QueryParam('limit', `${limit}`),
  566. new QueryParam('_sid', auth.sid)
  567. ];
  568. const response = await this.get<AudioStationResponse<AudioStationPlaylistListData>>(url, params);
  569. if (!response.success) {
  570. throw new Error(`AudioStation 获取歌单失败(code=${response.error?.code ?? 'unknown'})`);
  571. }
  572. const chunk = response.data?.playlists ?? [];
  573. const items = chunk
  574. .filter(item => item.id && item.name)
  575. .map(item => {
  576. const playlist: AudioStationPlaylist = {
  577. id: item.id as string,
  578. name: item.name as string,
  579. type: item.type,
  580. library: item.library,
  581. path: item.path
  582. };
  583. return playlist;
  584. });
  585. const total = response.data?.total ?? items.length;
  586. const nextStart = offset + items.length < total ? offset + items.length : null;
  587. const result: AudioStationPagedResponse<AudioStationPlaylist> = {
  588. items,
  589. nextStart
  590. };
  591. return result;
  592. }
  593. async getPlaylistSongsPage(
  594. account: WebDavAccount,
  595. playlistId: string,
  596. offset: number = 0,
  597. limit: number = 200
  598. ): Promise<AudioStationPagedResponse<AudioStationSong>> {
  599. const auth = await this.ensureAuth(account);
  600. const url = this.buildWebApiUrl(account, 'AudioStation/playlist.cgi');
  601. const params = [
  602. new QueryParam('api', 'SYNO.AudioStation.Playlist'),
  603. new QueryParam('version', '2'),
  604. new QueryParam('method', 'getinfo'),
  605. new QueryParam('library', 'all'),
  606. new QueryParam('id', playlistId),
  607. new QueryParam('offset', `${offset}`),
  608. new QueryParam('limit', `${limit}`),
  609. new QueryParam('sort_direction', 'ASC'),
  610. new QueryParam('additional', 'songs_song_tag,songs_song_audio'),
  611. new QueryParam('_sid', auth.sid)
  612. ];
  613. const response = await this.get<AudioStationResponse<AudioStationPlaylistInfoData>>(url, params);
  614. if (!response.success) {
  615. throw new Error(`AudioStation 获取歌单歌曲失败(code=${response.error?.code ?? 'unknown'})`);
  616. }
  617. const chunk = response.data?.songs ?? [];
  618. const items = chunk
  619. .filter(item => item.id)
  620. .map(item => {
  621. const audio = item.additional?.song_audio;
  622. const tag = item.additional?.song_tag;
  623. const duration = audio?.duration;
  624. const song: AudioStationSong = {
  625. id: item.id as string,
  626. title: item.title,
  627. path: item.path,
  628. album: tag?.album,
  629. albumArtist: tag?.album_artist,
  630. artist: tag?.artist,
  631. durationSeconds: duration ? Math.round(duration) : undefined,
  632. size: audio?.filesize,
  633. bitRate: audio?.bitrate,
  634. sampleRate: audio?.frequency,
  635. codec: audio?.codec,
  636. container: audio?.container,
  637. track: tag?.track,
  638. year: tag?.year
  639. };
  640. return song;
  641. });
  642. const total = response.data?.total ?? items.length;
  643. const nextStart = offset + items.length < total ? offset + items.length : null;
  644. const result: AudioStationPagedResponse<AudioStationSong> = {
  645. items,
  646. nextStart
  647. };
  648. return result;
  649. }
  650. async buildSongCoverUrl(account: WebDavAccount, songId: string | undefined): Promise<string | undefined> {
  651. if (!songId) {
  652. return undefined;
  653. }
  654. const auth = await this.ensureAuth(account);
  655. const params = [
  656. new QueryParam('api', 'SYNO.AudioStation.Cover'),
  657. new QueryParam('version', '1'),
  658. new QueryParam('method', 'getsongcover'),
  659. new QueryParam('library', 'all'),
  660. new QueryParam('id', songId),
  661. new QueryParam('_sid', auth.sid)
  662. ];
  663. const url = this.buildWebApiUrl(account, 'AudioStation/cover.cgi');
  664. return `${url}?${this.buildQuery(params)}`;
  665. }
  666. async buildAlbumCoverUrl(account: WebDavAccount, albumName?: string, albumArtist?: string): Promise<string | undefined> {
  667. if (!albumName) {
  668. return undefined;
  669. }
  670. const auth = await this.ensureAuth(account);
  671. const params = [
  672. new QueryParam('api', 'SYNO.AudioStation.Cover'),
  673. new QueryParam('version', '1'),
  674. new QueryParam('method', 'getcover'),
  675. new QueryParam('library', 'all'),
  676. new QueryParam('album_name', albumName),
  677. new QueryParam('_sid', auth.sid)
  678. ];
  679. if (albumArtist) {
  680. params.push(new QueryParam('album_artist_name', albumArtist));
  681. }
  682. const url = this.buildWebApiUrl(account, 'AudioStation/cover.cgi');
  683. return `${url}?${this.buildQuery(params)}`;
  684. }
  685. async getLyric(account: WebDavAccount, songId: string): Promise<string> {
  686. if (!songId) {
  687. return '';
  688. }
  689. const auth = await this.ensureAuth(account);
  690. const url = this.buildWebApiUrl(account, 'AudioStation/lyrics.cgi');
  691. const params = [
  692. new QueryParam('api', 'SYNO.AudioStation.Lyrics'),
  693. new QueryParam('version', '1'),
  694. new QueryParam('method', 'getlyrics'),
  695. new QueryParam('id', songId),
  696. new QueryParam('_sid', auth.sid)
  697. ];
  698. const response = await this.get<AudioStationResponse<Record<string, JsonValue>>>(url, params);
  699. if (!response.success) {
  700. return '';
  701. }
  702. return this.resolveLyricText(response.data);
  703. }
  704. async buildStreamUrl(account: WebDavAccount, songId: string): Promise<string> {
  705. if (!songId) {
  706. throw new Error('无效的AudioStation歌曲ID');
  707. }
  708. const auth = await this.ensureAuth(account);
  709. const shouldTranscode = songId.includes('_v_');
  710. const params = [
  711. new QueryParam('api', 'SYNO.AudioStation.Stream'),
  712. new QueryParam('version', '2'),
  713. new QueryParam('method', shouldTranscode ? 'transcode' : 'stream'),
  714. new QueryParam('id', songId),
  715. new QueryParam('_sid', auth.sid)
  716. ];
  717. if (shouldTranscode) {
  718. params.push(new QueryParam('format', 'mp3'));
  719. }
  720. const baseUrl = this.buildWebApiUrl(account, 'AudioStation/stream.cgi');
  721. const query = this.buildQuery(params);
  722. if (shouldTranscode) {
  723. return `${baseUrl}/0.mp3?${query}`;
  724. }
  725. return `${baseUrl}?${query}`;
  726. }
  727. private async ensureAuth(account: WebDavAccount): Promise<AudioStationAuthContext> {
  728. const key = this.buildCacheKey(account);
  729. const cached = this.authCache.get(key);
  730. if (cached?.sid) {
  731. return cached;
  732. }
  733. const auth = await this.login(account);
  734. this.authCache.set(key, auth);
  735. return auth;
  736. }
  737. private async login(account: WebDavAccount): Promise<AudioStationAuthContext> {
  738. if (!account.account || !account.password) {
  739. throw new Error('AudioStation 账号或密码为空');
  740. }
  741. const url = this.buildWebApiUrl(account, 'entry.cgi');
  742. const deviceId = this.getDeviceId();
  743. const params = [
  744. new QueryParam('api', 'SYNO.API.Auth'),
  745. new QueryParam('version', '6'),
  746. new QueryParam('method', 'login'),
  747. new QueryParam('session', 'audiostation'),
  748. new QueryParam('account', account.account),
  749. new QueryParam('passwd', account.password),
  750. new QueryParam('enable_device_token', 'yes'),
  751. new QueryParam('device_name', DEVICE_NAME),
  752. new QueryParam('device_id', deviceId)
  753. ];
  754. const response = await this.postForm<AudioStationResponse<AudioStationLoginResponseData>>(url, params);
  755. if (!response.success || !response.data?.sid) {
  756. const errorCode = response.error?.code ?? 'unknown';
  757. throw new Error(`AudioStation 登录失败(code=${errorCode})`);
  758. }
  759. const auth: AudioStationAuthContext = {
  760. sid: response.data.sid,
  761. did: response.data.did
  762. };
  763. void ServerLogUtil.info(TAG, `AudioStation 登录成功 sid=${auth.sid}`);
  764. return auth;
  765. }
  766. private buildCacheKey(account: WebDavAccount): string {
  767. return `${account.id ?? account.host}_${account.account}`;
  768. }
  769. private buildBaseUrl(account: WebDavAccount): string {
  770. const scheme = account.enableHttps ? 'https' : 'http';
  771. const port = account.port || (account.enableHttps ? 5001 : 5000);
  772. return `${scheme}://${account.host}:${port}`;
  773. }
  774. private buildWebApiUrl(account: WebDavAccount, path: string): string {
  775. const baseUrl = this.buildBaseUrl(account);
  776. const normalizedPath = path.startsWith('/') ? path : `/${path}`;
  777. return `${baseUrl}/webapi${normalizedPath}`;
  778. }
  779. private buildQuery(params: QueryParam[]): string {
  780. return params
  781. .filter(param => param.key && param.value !== undefined && param.value !== null)
  782. .map(param => `${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`)
  783. .join('&');
  784. }
  785. private async get<T>(url: string, params: QueryParam[]): Promise<T> {
  786. const httpRequest = http.createHttp();
  787. const query = this.buildQuery(params);
  788. const response = await httpRequest.request(`${url}?${query}`, {
  789. method: http.RequestMethod.GET,
  790. header: {
  791. Accept: 'application/json'
  792. },
  793. connectTimeout: 10000,
  794. readTimeout: 15000,
  795. expectDataType: http.HttpDataType.STRING
  796. });
  797. if (response.responseCode !== 200) {
  798. httpRequest.destroy();
  799. throw new Error(`AudioStation 请求失败: HTTP ${response.responseCode}`);
  800. }
  801. httpRequest.destroy();
  802. return this.parseResponse<T>(response.result);
  803. }
  804. private async post<T>(url: string, params: QueryParam[], body?: AudioStationRequestBody): Promise<T> {
  805. const httpRequest = http.createHttp();
  806. const query = this.buildQuery(params);
  807. const response = await httpRequest.request(query ? `${url}?${query}` : url, {
  808. method: http.RequestMethod.POST,
  809. header: {
  810. Accept: 'application/json',
  811. 'Content-Type': 'application/json'
  812. },
  813. extraData: body ? JSON.stringify(body) : undefined,
  814. connectTimeout: 10000,
  815. readTimeout: 15000,
  816. expectDataType: http.HttpDataType.STRING
  817. });
  818. if (response.responseCode !== 200) {
  819. httpRequest.destroy();
  820. throw new Error(`AudioStation 请求失败: HTTP ${response.responseCode}`);
  821. }
  822. httpRequest.destroy();
  823. return this.parseResponse<T>(response.result);
  824. }
  825. private async postForm<T>(url: string, params: QueryParam[]): Promise<T> {
  826. const httpRequest = http.createHttp();
  827. const body = this.buildQuery(params);
  828. const response = await httpRequest.request(url, {
  829. method: http.RequestMethod.POST,
  830. header: {
  831. Accept: 'application/json',
  832. 'Content-Type': 'application/x-www-form-urlencoded'
  833. },
  834. extraData: body,
  835. connectTimeout: 10000,
  836. readTimeout: 15000,
  837. expectDataType: http.HttpDataType.STRING
  838. });
  839. if (response.responseCode !== 200) {
  840. httpRequest.destroy();
  841. throw new Error(`AudioStation 请求失败: HTTP ${response.responseCode}`);
  842. }
  843. httpRequest.destroy();
  844. return this.parseResponse<T>(response.result);
  845. }
  846. private resolveLyricText(data?: Record<string, JsonValue>): string {
  847. if (!data) {
  848. return '';
  849. }
  850. const direct = this.pickLyricText(data);
  851. if (direct) {
  852. return direct;
  853. }
  854. const keys = Object.keys(data);
  855. for (let i = 0; i < keys.length; i++) {
  856. const key = keys[i];
  857. const value = data[key];
  858. if (value && typeof value === 'object') {
  859. const nested = value as Record<string, JsonValue>;
  860. const nestedText = this.pickLyricText(nested);
  861. if (nestedText) {
  862. return nestedText;
  863. }
  864. }
  865. }
  866. return '';
  867. }
  868. private pickLyricText(data: Record<string, JsonValue>): string {
  869. const candidates = ['lyrics', 'lyric', 'text'];
  870. for (let i = 0; i < candidates.length; i++) {
  871. const key = candidates[i];
  872. const value = data[key];
  873. if (typeof value === 'string' && value.trim().length > 0) {
  874. return value;
  875. }
  876. }
  877. return '';
  878. }
  879. private parseResponse<T>(payload: string | Object): T {
  880. if (typeof payload === 'string') {
  881. const trimmed = payload.trim();
  882. if (trimmed.length === 0) {
  883. return JSON.parse('{}') as T;
  884. }
  885. try {
  886. const parsed: JsonValue = JSON.parse(trimmed) as JsonValue;
  887. if (typeof parsed === 'string') {
  888. const inner = parsed.trim();
  889. if (inner.startsWith('{') || inner.startsWith('[')) {
  890. return JSON.parse(inner) as T;
  891. }
  892. }
  893. return parsed as T;
  894. } catch (_error) {
  895. return payload as T;
  896. }
  897. }
  898. return payload as T;
  899. }
  900. private getDeviceId(): string {
  901. const cached = PreferencesUtil.getStringSync(DEVICE_ID_KEY, '');
  902. if (cached && cached.length > 0) {
  903. return cached;
  904. }
  905. const id = `ttmusic_${Date.now().toString(36)}_${Math.floor(Math.random() * 100000)}`;
  906. PreferencesUtil.putSync(DEVICE_ID_KEY, id);
  907. return id;
  908. }
  909. }
  910. export const audioStationApi = new AudioStationApi();