diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index ffaf7a4..efcdbe0 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -17,7 +17,7 @@ import { import { b64Encode, b64Decode } from "../b64"; import { axiosImageFetcher, ImageFetcher } from "../images"; import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; -import { http, getJSON as getJSON2 } from "./http"; +import { getJSON as getJSON2 } from "./subsonic_http"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -61,6 +61,7 @@ export function isError( return (subsonicResponse as SubsonicError).error !== undefined; } +// todo: is this a good name? export type StreamClientApplication = (track: Track) => string; export const DEFAULT_CLIENT_APPLICATION = "bonob"; @@ -94,9 +95,12 @@ export interface SubsonicMusicLibrary extends MusicLibrary { export class Subsonic implements MusicService { url: string; + + // todo: does this need to be in here now? streamClientApplication: StreamClientApplication; // todo: why is this in here? externalImageFetcher: ImageFetcher; + base: Http; constructor( @@ -114,9 +118,6 @@ export class Subsonic implements MusicService { }); } - // todo: delete - http = (credentials: Credentials) => http(this.url, credentials); - authenticated = (credentials: Credentials, wrap: Http = this.base) => http2(wrap, { params: { @@ -168,8 +169,7 @@ export class Subsonic implements MusicService { credentials: SubsonicCredentials ): Promise => { const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( - this, - credentials, + this.streamClientApplication, this.authenticated(credentials, this.base) ); if (credentials.type == "navidrome") { diff --git a/src/subsonic/library.ts b/src/subsonic/library.ts index 15873b8..38f240a 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/library.ts @@ -27,8 +27,9 @@ import { Sortable, Track, } from "../music_service"; -import Subsonic, { +import { DODGY_IMAGE_NAME, + StreamClientApplication, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, @@ -38,8 +39,8 @@ import axios from "axios"; import { asURLSearchParams } from "../utils"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; //todo: rename http2 -> http -import { Http, http as http2 } from "../http"; -import { getRaw2 } from "./http"; +import { Http, http as http2, RequestParams } from "../http"; +import { getRaw2, getJSON as getJSON2 } from "./subsonic_http"; type album = { id: string; @@ -275,20 +276,22 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined => ); export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { - subsonic: Subsonic; - credentials: SubsonicCredentials; + streamClientApplication: StreamClientApplication; http: Http; constructor( - subsonic: Subsonic, - credentials: SubsonicCredentials, + streamClientApplication: StreamClientApplication, http: Http ) { - this.subsonic = subsonic; - this.credentials = credentials; + this.streamClientApplication = streamClientApplication; this.http = http; } + GET = (query: Partial) => ({ + asRAW: () => getRaw2(http2(this.http, query)), + asJSON: () => getJSON2(http2(this.http, query)), + }); + flavour = () => "subsonic"; bearerToken = (_: Credentials): TE.TaskEither => @@ -315,8 +318,10 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { album = (id: string): Promise => this.getAlbum(id); genres = () => - this.subsonic - .getJSON(this.credentials, "/rest/getGenres") + this.GET({ + url: "/rest/getGenres", + }) + .asJSON() .then((it) => pipe( it.genres.genre || [], @@ -328,10 +333,13 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { ); tracks = (albumId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { + this.GET({ + url: "/rest/getAlbum", + params: { id: albumId, - }) + }, + }) + .asJSON() .then((it) => it.album) .then((album) => (album.song || []).map((song) => asTrack(asAlbum(album), song)) @@ -352,21 +360,23 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { const thingsToUpdate = []; if (track.rating.love != rating.love) { thingsToUpdate.push( - this.subsonic.getJSON( - this.credentials, - `/rest/${rating.love ? "star" : "unstar"}`, - { + this.GET({ + url: `/rest/${rating.love ? "star" : "unstar"}`, + params: { id: trackId, - } - ) + }, + }).asJSON() ); } if (track.rating.stars != rating.stars) { thingsToUpdate.push( - this.subsonic.getJSON(this.credentials, `/rest/setRating`, { - id: trackId, - rating: rating.stars, - }) + this.GET({ + url: `/rest/setRating`, + params: { + id: trackId, + rating: rating.stars, + }, + }).asJSON() ); } return Promise.all(thingsToUpdate); @@ -383,27 +393,26 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }) => // todo: all these headers and stuff can be rolled into httpeee this.getTrack(trackId).then((track) => - getRaw2( - http2(this.http, { - url: `/rest/stream`, - params: { - 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", - }) - ) + this.GET({ + url: "/rest/stream", + params: { + 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", + }) + .asRAW() .then((res) => ({ status: res.status, headers: { @@ -420,7 +429,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { Promise.resolve(coverArtURN) .then((it) => assertSystem(it, "subsonic")) .then((it) => it.resource.split(":")[1]!) - .then((it) => this.getCoverArt(this.credentials, it, size)) + .then((it) => this.getCoverArt(it, size)) .then((res) => ({ contentType: res.headers["content-type"], data: Buffer.from(res.data, "binary"), @@ -433,20 +442,26 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }); scrobble = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { + this.GET({ + url: `/rest/scrobble`, + params: { id, submission: true, - }) + }, + }) + .asJSON() .then((_) => true) .catch(() => false); nowPlaying = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { + this.GET({ + url: `/rest/scrobble`, + params: { id, submission: false, - }) + }, + }) + .asJSON() .then((_) => true) .catch(() => false); @@ -473,18 +488,21 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { ); playlists = async () => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylists") + this.GET({ url: "/rest/getPlaylists" }) + .asJSON() .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", { + this.GET({ + url: "/rest/getPlaylist", + params: { id, - }) + }, + }) + .asJSON() .then((it) => it.playlist) .then((playlist) => { let trackNumber = 1; @@ -510,43 +528,54 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }); createPlaylist = async (name: string) => - this.subsonic - .getJSON(this.credentials, "/rest/createPlaylist", { + this.GET({ + url: "/rest/createPlaylist", + params: { name, - }) + }, + }) + .asJSON() .then((it) => it.playlist) .then((it) => ({ id: it.id, name: it.name })); deletePlaylist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/deletePlaylist", { + this.GET({ + url: "/rest/deletePlaylist", + params: { id, - }) + }, + }) + .asJSON() .then((_) => true); addToPlaylist = async (playlistId: string, trackId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { + this.GET({ + url: "/rest/updatePlaylist", + params: { playlistId, songIdToAdd: trackId, - }) + }, + }) + .asJSON() .then((_) => true); removeFromPlaylist = async (playlistId: string, indicies: number[]) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { + this.GET({ + url: "/rest/updatePlaylist", + params: { playlistId, songIndexToRemove: indicies, - }) + }, + }) + .asJSON() .then((_) => true); similarSongs = async (id: string) => - this.subsonic - .getJSON( - this.credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } - ) + this.GET({ + url: "/rest/getSimilarSongs2", + params: { id, count: 50 }, + }) + .asJSON() .then((it) => it.similarSongs2.song || []) .then((songs) => Promise.all( @@ -558,11 +587,14 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { topSongs = async (artistId: string) => this.getArtist(artistId).then(({ name }) => - this.subsonic - .getJSON(this.credentials, "/rest/getTopSongs", { + this.GET({ + url: "/rest/getTopSongs", + params: { artist: name, count: 50, - }) + }, + }) + .asJSON() .then((it) => it.topSongs.song || []) .then((songs) => Promise.all( @@ -576,8 +608,8 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { private getArtists = (): Promise< (IdName & { albumCount: number; image: BUrn | undefined })[] > => - this.subsonic - .getJSON(this.credentials, "/rest/getArtists") + this.GET({ url: "/rest/getArtists" }) + .asJSON() .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) .then((artists) => artists.map((artist) => ({ @@ -601,16 +633,15 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { l: string | undefined; }; }> => - this.subsonic - .getJSON( - this.credentials, - "/rest/getArtistInfo2", - { - id, - count: 50, - includeNotPresent: true, - } - ) + this.GET({ + url: "/rest/getArtistInfo2", + params: { + id, + count: 50, + includeNotPresent: true, + }, + }) + .asJSON() .then((it) => it.artistInfo2) .then((it) => ({ images: { @@ -630,8 +661,8 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })); private getAlbum = (id: string): Promise => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { id }) + this.GET({ url: "/rest/getAlbum", params: { id } }) + .asJSON() .then((it) => it.album) .then((album) => ({ id: album.id, @@ -648,10 +679,13 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { ): Promise< IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } > => - this.subsonic - .getJSON(this.credentials, "/rest/getArtist", { + this.GET({ + url: "/rest/getArtist", + params: { id, - }) + }, + }) + .asJSON() .then((it) => it.artist) .then((it) => ({ id: it.id, @@ -679,18 +713,21 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }) ); - private getCoverArt = (credentials: Credentials, id: string, size?: number) => - getRaw2(http2(this.subsonic.authenticated(credentials), { + private getCoverArt = (id: string, size?: number) => + this.GET({ url: "/rest/getCoverArt", params: { id, size }, responseType: "arraybuffer", - })); + }).asRAW(); private getTrack = (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getSong", { + this.GET({ + url: "/rest/getSong", + params: { id, - }) + }, + }) + .asJSON() .then((it) => it.song) .then((song) => this.getAlbum(song.albumId!).then((album) => asTrack(album, song)) @@ -708,13 +745,16 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })); private search3 = (q: any) => - this.subsonic - .getJSON(this.credentials, "/rest/search3", { + this.GET({ + url: "/rest/search3", + params: { artistCount: 0, albumCount: 0, songCount: 0, ...q, - }) + }, + }) + .asJSON() .then((it) => ({ artists: it.searchResult3.artist || [], albums: it.searchResult3.album || [], @@ -726,17 +766,16 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { this.getArtists().then((it) => inject(it, (total, artist) => total + artist.albumCount, 0) ), - this.subsonic - .getJSON( - this.credentials, - "/rest/getAlbumList2", - { - type: AlbumQueryTypeToSubsonicType[q.type], - ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - size: 500, - offset: q._index, - } - ) + this.GET({ + url: "/rest/getAlbumList2", + params: { + type: AlbumQueryTypeToSubsonicType[q.type], + ...(q.genre ? { genre: b64Decode(q.genre) } : {}), + size: 500, + offset: q._index, + }, + }) + .asJSON() .then((response) => response.albumList2.album || []) .then(this.toAlbumSummary), ]).then(([total, albums]) => ({ @@ -758,11 +797,12 @@ export const navidromeMusicLibrary = ( pipe( TE.tryCatch( () => + // todo: not hardcode axios in here axios({ - method: 'post', + method: "post", baseURL: url, url: `/auth/login`, - data: _.pick(credentials, "username", "password") + data: _.pick(credentials, "username", "password"), }), () => new AuthFailure("Failed to get bearerToken") ), diff --git a/src/subsonic/http.ts b/src/subsonic/subsonic_http.ts similarity index 79% rename from src/subsonic/http.ts rename to src/subsonic/subsonic_http.ts index 57acfdb..8a9d975 100644 --- a/src/subsonic/http.ts +++ b/src/subsonic/subsonic_http.ts @@ -54,28 +54,6 @@ export interface HTTP { ): Promise; } -// export const basic = (opts : AxiosRequestConfig) => axios(opts); - -// function whatDoesItLookLike() { -// const basic = axios; - -// const authenticatedClient = httpee(axios, chain( -// baseUrl("http://foobar"), -// subsonicAuth({username: 'bob', password: 'foo'}) -// )); -// const jsonClient = httpee(authenticatedClient, formatJson()) - -// jsonClient({ }) - -// } - -// .then((response) => response.data as SubsonicEnvelope) -// .then((json) => json["subsonic-response"]) -// .then((json) => { -// if (isError(json)) throw `Subsonic error:${json.error.message}`; -// else return json as unknown as T; -// }); - export const raw = (response: AxiosPromise) => response .catch((e) => { @@ -87,7 +65,6 @@ export const raw = (response: AxiosPromise) => } else return response; }); - // todo: delete export const getRaw2 = (http: Http) => http({ method: "get" }) .catch((e) => { diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts index 3654c41..e2039d7 100644 --- a/tests/subsonic/generic.test.ts +++ b/tests/subsonic/generic.test.ts @@ -44,7 +44,7 @@ import { SubsonicGenericMusicLibrary, } from "../../src/subsonic/library"; import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; -import Subsonic, { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; +import { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; import { b64Encode } from "../../src/b64"; import { http as http2 } from "../../src/http"; @@ -490,7 +490,6 @@ describe("SubsonicGenericMusicLibrary", () => { const salt = "saltysalty"; const streamClientApplication = jest.fn(); - const subsonic = new Subsonic(url, streamClientApplication) const authParams = { u: username, @@ -510,13 +509,7 @@ describe("SubsonicGenericMusicLibrary", () => { }; const generic = new SubsonicGenericMusicLibrary( - subsonic, - { - username, - password, - type: 'subsonic', - bearer: undefined - }, + streamClientApplication, // todo: all this stuff doesnt need to be defaulted in here. http2(mockAxios, { baseURL,