From 1c94a6d5658f111827f77408d3e097d58a14d233 Mon Sep 17 00:00:00 2001 From: simojenki Date: Wed, 5 Jan 2022 10:15:01 +1100 Subject: [PATCH] Move subsonic generic library into proper class --- src/subsonic.ts | 656 +++++++++++++++++++++++++----------------------- 1 file changed, 346 insertions(+), 310 deletions(-) diff --git a/src/subsonic.ts b/src/subsonic.ts index 89440f8..28527ea 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -431,6 +431,312 @@ interface SubsonicMusicLibrary extends MusicLibrary { ): TE.TaskEither; } +export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { + subsonic: Subsonic; + credentials: SubsonicCredentials; + + constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { + this.subsonic = subsonic; + this.credentials = credentials; + } + + flavour = () => "subsonic"; + + bearerToken = (_: Credentials) => TE.right(undefined); + + artists = (q: ArtistQuery): Promise> => + this.subsonic + .getArtists(this.credentials) + .then(slice2(q)) + .then(([page, total]) => ({ + total, + results: page.map((it) => ({ + id: it.id, + name: it.name, + sortName: it.name, + image: it.image, + })), + })); + + artist = async (id: string): Promise => + this.subsonic.getArtistWithInfo(this.credentials, id); + + albums = async (q: AlbumQuery): Promise> => + this.subsonic.getAlbumList2(this.credentials, q); + + album = (id: string): Promise => + this.subsonic.getAlbum(this.credentials, id); + + genres = () => + this.subsonic + .getJSON(this.credentials, "/rest/getGenres") + .then((it) => + pipe( + it.genres.genre || [], + A.filter((it) => it.albumCount > 0), + A.map((it) => it.value), + A.sort(ordString), + A.map((it) => ({ id: b64Encode(it), name: it })) + ) + ); + + tracks = (albumId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getAlbum", { + id: albumId, + }) + .then((it) => it.album) + .then((album) => + (album.song || []).map((song) => asTrack(asAlbum(album), song)) + ); + + track = (trackId: string) => + this.subsonic.getTrack(this.credentials, trackId); + + rate = (trackId: string, rating: Rating) => + Promise.resolve(true) + .then(() => { + if (rating.stars >= 0 && rating.stars <= 5) { + return this.subsonic.getTrack(this.credentials, trackId); + } else { + throw `Invalid rating.stars value of ${rating.stars}`; + } + }) + .then((track) => { + const thingsToUpdate = []; + if (track.rating.love != rating.love) { + thingsToUpdate.push( + this.subsonic.getJSON( + this.credentials, + `/rest/${rating.love ? "star" : "unstar"}`, + { + id: trackId, + } + ) + ); + } + if (track.rating.stars != rating.stars) { + thingsToUpdate.push( + this.subsonic.getJSON(this.credentials, `/rest/setRating`, { + id: trackId, + rating: rating.stars, + }) + ); + } + return Promise.all(thingsToUpdate); + }) + .then(() => true) + .catch(() => false); + + stream = async ({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }) => + this.subsonic.getTrack(this.credentials, trackId).then((track) => + this.subsonic + .get( + this.credentials, + `/rest/stream`, + { + id: trackId, + c: this.subsonic.streamClientApplication(track), + }, + { + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + } + ) + .then((res) => ({ + status: res.status, + headers: { + "content-type": res.headers["content-type"], + "content-length": res.headers["content-length"], + "content-range": res.headers["content-range"], + "accept-ranges": res.headers["accept-ranges"], + }, + stream: res.data, + })) + ); + + coverArt = async (coverArtURN: BUrn, size?: number) => + Promise.resolve(coverArtURN) + .then((it) => assertSystem(it, "subsonic")) + .then((it) => it.resource.split(":")[1]!) + .then((it) => this.subsonic.getCoverArt(this.credentials, it, size)) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); + return undefined; + }); + + scrobble = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: true, + }) + .then((_) => true) + .catch(() => false); + + nowPlaying = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: false, + }) + .then((_) => true) + .catch(() => false); + + searchArtists = async (query: string) => + this.subsonic + .search3(this.credentials, { query, artistCount: 20 }) + .then(({ artists }) => + artists.map((artist) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + searchAlbums = async (query: string) => + this.subsonic + .search3(this.credentials, { query, albumCount: 20 }) + .then(({ albums }) => this.subsonic.toAlbumSummary(albums)); + + searchTracks = async (query: string) => + this.subsonic + .search3(this.credentials, { query, songCount: 20 }) + .then(({ songs }) => + Promise.all( + songs.map((it) => this.subsonic.getTrack(this.credentials, it.id)) + ) + ); + + playlists = async () => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylists") + .then((it) => it.playlists.playlist || []) + .then((playlists) => + playlists.map((it) => ({ id: it.id, name: it.name })) + ); + + playlist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylist", { + id, + }) + .then((it) => it.playlist) + .then((playlist) => { + let trackNumber = 1; + return { + id: playlist.id, + name: playlist.name, + entries: (playlist.entry || []).map((entry) => ({ + ...asTrack( + { + id: entry.albumId!, + name: entry.album!, + year: entry.year, + genre: maybeAsGenre(entry.genre), + artistName: entry.artist, + artistId: entry.artistId, + coverArt: coverArtURN(entry.coverArt), + }, + entry + ), + number: trackNumber++, + })), + }; + }); + + createPlaylist = async (name: string) => + this.subsonic + .getJSON(this.credentials, "/rest/createPlaylist", { + name, + }) + .then((it) => it.playlist) + .then((it) => ({ id: it.id, name: it.name })); + + deletePlaylist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/deletePlaylist", { + id, + }) + .then((_) => true); + + addToPlaylist = async (playlistId: string, trackId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIdToAdd: trackId, + }) + .then((_) => true); + + removeFromPlaylist = async (playlistId: string, indicies: number[]) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIndexToRemove: indicies, + }) + .then((_) => true); + + similarSongs = async (id: string) => + this.subsonic + .getJSON( + this.credentials, + "/rest/getSimilarSongs2", + { id, count: 50 } + ) + .then((it) => it.similarSongs2.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.subsonic + .getAlbum(this.credentials, song.albumId!) + .then((album) => asTrack(album, song)) + ) + ) + ); + + topSongs = async (artistId: string) => + this.subsonic.getArtist(this.credentials, artistId).then(({ name }) => + this.subsonic + .getJSON(this.credentials, "/rest/getTopSongs", { + artist: name, + count: 50, + }) + .then((it) => it.topSongs.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.subsonic + .getAlbum(this.credentials, song.albumId!) + .then((album) => asTrack(album, song)) + ) + ) + ) + ); +} + export class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; @@ -699,281 +1005,8 @@ export class Subsonic implements MusicService { private libraryFor = ( credentials: SubsonicCredentials ): Promise => { - const subsonic = this; - - const genericSubsonic: SubsonicMusicLibrary = { - flavour: () => "subsonic", - bearerToken: (_: Credentials) => TE.right(undefined), - artists: (q: ArtistQuery): Promise> => - subsonic - .getArtists(credentials) - .then(slice2(q)) - .then(([page, total]) => ({ - total, - results: page.map((it) => ({ - id: it.id, - name: it.name, - sortName: it.name, - image: it.image, - })), - })), - artist: async (id: string): Promise => - subsonic.getArtistWithInfo(credentials, id), - albums: async (q: AlbumQuery): Promise> => - subsonic.getAlbumList2(credentials, q), - album: (id: string): Promise => subsonic.getAlbum(credentials, id), - genres: () => - subsonic - .getJSON(credentials, "/rest/getGenres") - .then((it) => - pipe( - it.genres.genre || [], - A.filter((it) => it.albumCount > 0), - A.map((it) => it.value), - A.sort(ordString), - A.map((it) => ({ id: b64Encode(it), name: it })) - ) - ), - tracks: (albumId: string) => - subsonic - .getJSON(credentials, "/rest/getAlbum", { - id: albumId, - }) - .then((it) => it.album) - .then((album) => - (album.song || []).map((song) => asTrack(asAlbum(album), song)) - ), - track: (trackId: string) => subsonic.getTrack(credentials, trackId), - rate: (trackId: string, rating: Rating) => - Promise.resolve(true) - .then(() => { - if (rating.stars >= 0 && rating.stars <= 5) { - return subsonic.getTrack(credentials, trackId); - } else { - throw `Invalid rating.stars value of ${rating.stars}`; - } - }) - .then((track) => { - const thingsToUpdate = []; - if (track.rating.love != rating.love) { - thingsToUpdate.push( - subsonic.getJSON( - credentials, - `/rest/${rating.love ? "star" : "unstar"}`, - { - id: trackId, - } - ) - ); - } - if (track.rating.stars != rating.stars) { - thingsToUpdate.push( - subsonic.getJSON(credentials, `/rest/setRating`, { - id: trackId, - rating: rating.stars, - }) - ); - } - return Promise.all(thingsToUpdate); - }) - .then(() => true) - .catch(() => false), - stream: async ({ - trackId, - range, - }: { - trackId: string; - range: string | undefined; - }) => - subsonic.getTrack(credentials, trackId).then((track) => - subsonic - .get( - credentials, - `/rest/stream`, - { - id: trackId, - c: this.streamClientApplication(track), - }, - { - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - "User-Agent": USER_AGENT, - })) - ), - responseType: "stream", - } - ) - .then((res) => ({ - status: res.status, - headers: { - "content-type": res.headers["content-type"], - "content-length": res.headers["content-length"], - "content-range": res.headers["content-range"], - "accept-ranges": res.headers["accept-ranges"], - }, - stream: res.data, - })) - ), - coverArt: async (coverArtURN: BUrn, size?: number) => - Promise.resolve(coverArtURN) - .then((it) => assertSystem(it, "subsonic")) - .then((it) => it.resource.split(":")[1]!) - .then((it) => subsonic.getCoverArt(credentials, it, size)) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })) - .catch((e) => { - logger.error( - `Failed getting coverArt for urn:'${coverArtURN}': ${e}` - ); - return undefined; - }), - scrobble: async (id: string) => - subsonic - .getJSON(credentials, `/rest/scrobble`, { - id, - submission: true, - }) - .then((_) => true) - .catch(() => false), - nowPlaying: async (id: string) => - subsonic - .getJSON(credentials, `/rest/scrobble`, { - id, - submission: false, - }) - .then((_) => true) - .catch(() => false), - searchArtists: async (query: string) => - subsonic - .search3(credentials, { query, artistCount: 20 }) - .then(({ artists }) => - artists.map((artist) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ), - searchAlbums: async (query: string) => - subsonic - .search3(credentials, { query, albumCount: 20 }) - .then(({ albums }) => subsonic.toAlbumSummary(albums)), - searchTracks: async (query: string) => - subsonic - .search3(credentials, { query, songCount: 20 }) - .then(({ songs }) => - Promise.all( - songs.map((it) => subsonic.getTrack(credentials, it.id)) - ) - ), - playlists: async () => - subsonic - .getJSON(credentials, "/rest/getPlaylists") - .then((it) => it.playlists.playlist || []) - .then((playlists) => - playlists.map((it) => ({ id: it.id, name: it.name })) - ), - playlist: async (id: string) => - subsonic - .getJSON(credentials, "/rest/getPlaylist", { - id, - }) - .then((it) => it.playlist) - .then((playlist) => { - let trackNumber = 1; - return { - id: playlist.id, - name: playlist.name, - entries: (playlist.entry || []).map((entry) => ({ - ...asTrack( - { - id: entry.albumId!, - name: entry.album!, - year: entry.year, - genre: maybeAsGenre(entry.genre), - artistName: entry.artist, - artistId: entry.artistId, - coverArt: coverArtURN(entry.coverArt), - }, - entry - ), - number: trackNumber++, - })), - }; - }), - createPlaylist: async (name: string) => - subsonic - .getJSON(credentials, "/rest/createPlaylist", { - name, - }) - .then((it) => it.playlist) - .then((it) => ({ id: it.id, name: it.name })), - deletePlaylist: async (id: string) => - subsonic - .getJSON(credentials, "/rest/deletePlaylist", { - id, - }) - .then((_) => true), - addToPlaylist: async (playlistId: string, trackId: string) => - subsonic - .getJSON(credentials, "/rest/updatePlaylist", { - playlistId, - songIdToAdd: trackId, - }) - .then((_) => true), - removeFromPlaylist: async (playlistId: string, indicies: number[]) => - subsonic - .getJSON(credentials, "/rest/updatePlaylist", { - playlistId, - songIndexToRemove: indicies, - }) - .then((_) => true), - similarSongs: async (id: string) => - subsonic - .getJSON( - credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } - ) - .then((it) => it.similarSongs2.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - subsonic - .getAlbum(credentials, song.albumId!) - .then((album) => asTrack(album, song)) - ) - ) - ), - topSongs: async (artistId: string) => - subsonic.getArtist(credentials, artistId).then(({ name }) => - subsonic - .getJSON(credentials, "/rest/getTopSongs", { - artist: name, - count: 50, - }) - .then((it) => it.topSongs.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - subsonic - .getAlbum(credentials, song.albumId!) - .then((album) => asTrack(album, song)) - ) - ) - ) - ), - }; + const genericSubsonic: SubsonicMusicLibrary = + new SubsonicGenericMusicLibrary(this, credentials); if (credentials.type == "navidrome") { return Promise.resolve({ @@ -991,46 +1024,49 @@ export class Subsonic implements MusicService { ), TE.map((it) => it.data.token as string | undefined) ), - artists: async (q: ArtistQuery): Promise> => - { - let params: any = { - _sort: "name", - _order: "ASC", - _start: q._index || "0", + artists: async ( + q: ArtistQuery + ): Promise> => { + let params: any = { + _sort: "name", + _order: "ASC", + _start: q._index || "0", + }; + if (q._count) { + params = { + ...params, + _end: (q._index || 0) + q._count, }; - if(q._count) { - params = { - ...params, - _end: (q._index || 0) + q._count - } - } - - return axios - .get(`${this.url}/api/artist`, { - params: asURLSearchParams(params), - headers: { - "User-Agent": USER_AGENT, - "x-nd-authorization": `Bearer ${credentials.bearer}`, - }, - }) - .catch((e) => { - throw `Navidrome failed with: ${e}`; - }) - .then((response) => { - if (response.status != 200 && response.status != 206) { - throw `Navidrome failed with a ${ - response.status || "no!" - } status`; - } else return response; - }) - .then((it) => ({ - results: (it.data as NDArtist[]).map(artistSummaryFromNDArtist), - total: Number.parseInt(it.headers["x-total-count"] || "0") - })) } + + return axios + .get(`${this.url}/api/artist`, { + params: asURLSearchParams(params), + headers: { + "User-Agent": USER_AGENT, + "x-nd-authorization": `Bearer ${credentials.bearer}`, + }, + }) + .catch((e) => { + throw `Navidrome failed with: ${e}`; + }) + .then((response) => { + if (response.status != 200 && response.status != 206) { + throw `Navidrome failed with a ${ + response.status || "no!" + } status`; + } else return response; + }) + .then((it) => ({ + results: (it.data as NDArtist[]).map(artistSummaryFromNDArtist), + total: Number.parseInt(it.headers["x-total-count"] || "0"), + })); + }, }); } else { return Promise.resolve(genericSubsonic); } }; } + +export default Subsonic;