AudioStationApi.ets 30 KB

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