a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1// subsonic api client
2
3class SubsonicAPI {
4 constructor(serverUrl, username, token, salt) {
5 this.serverUrl = serverUrl.replace(/\/$/, "");
6 this.username = username;
7 this.token = token;
8 this.salt = salt;
9 this.clientName = "tinysub";
10 this.apiVersion = "1.16.1";
11 this.requestTimeout = 30000;
12 }
13
14 // build auth params from stored token+salt
15 getAuthParams() {
16 return {
17 u: this.username,
18 t: this.token,
19 s: this.salt,
20 c: this.clientName,
21 v: this.apiVersion,
22 };
23 }
24
25 // build authenticated REST API URL with params
26 _buildAuthUrl(method, params = {}) {
27 return `${this.serverUrl}/rest/${method}?${new URLSearchParams({
28 ...this.getAuthParams(),
29 ...params,
30 })}`;
31 }
32
33 // make request to subsonic rest api with error handling
34 async request(method, params = {}) {
35 if (!method || typeof method !== "string" || method.trim().length === 0) {
36 throw new Error("API method is required");
37 }
38
39 const queryParams = new URLSearchParams({
40 ...this.getAuthParams(),
41 f: "json",
42 ...params,
43 });
44
45 const controller = new AbortController();
46 const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
47
48 try {
49 const response = await fetch(
50 `${this.serverUrl}/rest/${method}?${queryParams}`,
51 { signal: controller.signal },
52 );
53
54 if (!response.ok) {
55 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
56 }
57
58 const data = await response.json();
59
60 if (data["subsonic-response"]?.status === "ok") {
61 return data["subsonic-response"];
62 }
63 throw new Error(data["subsonic-response"]?.error?.message || "API error");
64 } catch (error) {
65 if (error.name === "AbortError") {
66 const timeoutError = new Error(
67 `Request timeout after ${this.requestTimeout / 1000}s`,
68 );
69 console.error(`[API] ${method}:`, timeoutError.message);
70 throw timeoutError;
71 }
72 const err = new Error(`Request failed: ${error.message}`);
73 console.error(`[API] ${method}:`, error);
74 throw err;
75 } finally {
76 clearTimeout(timeoutId);
77 }
78 }
79
80 ping() {
81 return this.request("ping.view");
82 }
83
84 getArtists() {
85 return this.request("getArtists.view");
86 }
87
88 _validateAndRequest(id, method, params, context) {
89 const validation = validateId(id, context);
90 if (!validation.valid) throw new Error(validation.error);
91 return this.request(method, { ...params, id });
92 }
93
94 _validateAndBuildUrl(id, method, params, context) {
95 const validation = validateId(id, context);
96 if (!validation.valid) throw new Error(validation.error);
97 return this._buildAuthUrl(method, { ...params, id });
98 }
99
100 getArtist(id) {
101 return this._validateAndRequest(id, "getArtist.view", {}, "Artist ID");
102 }
103
104 getAlbum(id) {
105 return this._validateAndRequest(id, "getAlbum.view", {}, "Album ID");
106 }
107
108 getPlaylists() {
109 return this.request("getPlaylists.view");
110 }
111
112 getPlaylist(id) {
113 return this._validateAndRequest(id, "getPlaylist.view", {}, "Playlist ID");
114 }
115
116 getStreamUrl(id) {
117 return this._validateAndBuildUrl(id, "stream.view", {}, "Stream ID");
118 }
119
120 getCoverArtUrl(id, size = 64) {
121 return this._validateAndBuildUrl(
122 id,
123 "getCoverArt.view",
124 { size },
125 "Cover Art ID",
126 );
127 }
128
129 scrobble(id) {
130 return this._validateAndRequest(id, "scrobble.view", {}, "Scrobble ID");
131 }
132
133 nowPlaying(id) {
134 return this._validateAndRequest(
135 id,
136 "scrobble.view",
137 { submission: false },
138 "Song ID",
139 );
140 }
141
142 getStarred2() {
143 return this.request("getStarred2.view");
144 }
145
146 star(id) {
147 return this._validateAndRequest(id, "star.view", {}, "Song ID");
148 }
149
150 unstar(id) {
151 return this._validateAndRequest(id, "unstar.view", {}, "Song ID");
152 }
153
154 getLyricsBySongId(id) {
155 return this._validateAndRequest(
156 id,
157 "getLyricsBySongId.view",
158 {},
159 "Song ID",
160 );
161 }
162
163 getLyrics(artist, title) {
164 if (!artist || !title) {
165 throw new Error("Artist and title are required for getLyrics");
166 }
167 return this.request("getLyrics.view", { artist, title });
168 }
169}