a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1import SparkMD5 from "spark-md5";
2
3export interface Artist {
4 id: string;
5 name: string;
6 coverArt?: string;
7}
8export interface Album {
9 id: string;
10 name: string;
11 artist: string;
12 artistId: string;
13 coverArt?: string;
14}
15export interface Playlist {
16 id: string;
17 name: string;
18 coverArt?: string;
19 readonly?: boolean;
20 owner?: string;
21 public?: boolean;
22}
23export interface Song {
24 id: string;
25 title: string;
26 artist: string;
27 album: string;
28 duration?: number;
29 coverArt?: string;
30 albumId?: string;
31 starred?: string;
32 userRating?: number;
33 discNumber?: number;
34 track?: number;
35 replayGain?: {
36 trackGain?: number;
37 albumGain?: number;
38 trackPeak?: number;
39 albumPeak?: number;
40 };
41}
42
43export interface Credentials {
44 server: string;
45 username: string;
46 token: string;
47 salt: string;
48}
49
50export let credentials: Credentials | null = null;
51
52export const setCredentials = (c: Credentials | null) => {
53 credentials = c;
54 c
55 ? localStorage.setItem("tinysub_credentials", JSON.stringify(c))
56 : localStorage.removeItem("tinysub_credentials");
57};
58
59export const asArray = (v: any) => (Array.isArray(v) ? v : v ? [v] : []);
60
61const getParams = (params: any = {}) => {
62 if (!credentials) throw "Auth required";
63 const p = new URLSearchParams({
64 u: credentials.username,
65 t: credentials.token,
66 s: credentials.salt,
67 v: "1.16.1",
68 c: "tinysub",
69 f: "json",
70 });
71 Object.entries(params).forEach(([k, v]) => {
72 if (Array.isArray(v)) v.forEach((val) => p.append(k, val));
73 else if (v !== undefined) p.append(k, String(v));
74 });
75 return p;
76};
77
78export const buildUrl = (method: string, params: any = {}) =>
79 `${credentials!.server.replace(/\/$/, "")}/rest/${method}?${getParams(params)}`;
80
81const request = async (method: string, params: any = {}, isPost = false) => {
82 const url = buildUrl(method, isPost ? {} : params);
83 const options: RequestInit = isPost
84 ? {
85 method: "POST",
86 headers: { "Content-Type": "application/x-www-form-urlencoded" },
87 body: getParams(params).toString(),
88 }
89 : {};
90 const res = await fetch(isPost ? url.split("?")[0] : url, options);
91 const response = (await res.json())["subsonic-response"];
92 if (response.status === "failed")
93 throw response.error?.message || "API error";
94 return response;
95};
96
97export const songCache = new Map<string, Song>();
98export const internSong = (s: any): Song => {
99 if (!s || !s.id) return s;
100 const cached = songCache.get(s.id);
101 if (cached) {
102 Object.assign(cached, s);
103 return cached;
104 }
105 const fresh = $state({ ...s });
106 songCache.set(s.id, fresh);
107 return fresh;
108};
109
110export const api = {
111 ping: () => request("ping"),
112 artists: () =>
113 request("getArtists").then((response) =>
114 asArray(response.artists?.index).flatMap((index: any) =>
115 asArray(index.artist),
116 ),
117 ),
118 playlists: () =>
119 request("getPlaylists").then((response) =>
120 asArray(response.playlists?.playlist),
121 ),
122 artist: (id: string) =>
123 request("getArtist", { id }).then((response) =>
124 asArray(response.artist.album),
125 ),
126 album: (id: string) =>
127 request("getAlbum", { id }).then((response) =>
128 asArray(response.album.song).map(internSong),
129 ),
130 playlist: (id: string) =>
131 request("getPlaylist", { id }).then((response) =>
132 asArray(response.playlist.entry).map(internSong),
133 ),
134 search: (query: string) =>
135 request("search3", { query }).then((response) => {
136 const results = response.searchResult3 || {};
137 if (results.song) results.song = asArray(results.song).map(internSong);
138 return results;
139 }),
140 stream: (id: string) => buildUrl("stream", { id }),
141 art: (id: string, size = 128) => buildUrl("getCoverArt", { id, size }),
142 star: (id: string) => request("star", { id }),
143 unstar: (id: string) => request("unstar", { id }),
144 setRating: (id: string, rating: number) =>
145 request("setRating", { id, rating }),
146 lyricsById: (id: string) => request("getLyricsBySongId", { id }),
147 lyrics: (artist: string, title: string) =>
148 request("getLyrics", { artist, title }),
149 createPlaylist: (name?: string, songId?: string[], playlistId?: string) =>
150 request("createPlaylist", { name, songId, playlistId }, true),
151 updatePlaylist: (
152 playlistId: string,
153 name?: string,
154 songIdToAdd?: string[],
155 songIndexToRemove?: number[],
156 isPublic?: boolean,
157 ) =>
158 request(
159 "updatePlaylist",
160 {
161 playlistId,
162 name,
163 songIdToAdd,
164 songIndexToRemove,
165 public: isPublic,
166 },
167 true,
168 ),
169 deletePlaylist: (id: string) => request("deletePlaylist", { id }),
170 savePlayQueue: (id: string[], current?: string, position?: number) =>
171 request("savePlayQueue", { id, current, position }, true),
172 getPlayQueue: () => request("getPlayQueue"),
173 scrobble: (id: string, submission = true) =>
174 request("scrobble", { id, submission }),
175};
176
177export const createToken = (password: string) => {
178 const salt = Math.random().toString(36).substring(2, 8);
179 return { token: SparkMD5.hash(password + salt), salt };
180};