a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 180 lines 4.9 kB view raw
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};