diff --git a/src/smapi_auth.ts b/src/smapi_auth.ts index c9059f0..e35df38 100644 --- a/src/smapi_auth.ts +++ b/src/smapi_auth.ts @@ -108,16 +108,16 @@ export const smapiTokenAsString = (smapiToken: SmapiToken) => b64Encode(JSON.str })); export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => JSON.parse(b64Decode(smapiTokenString)); -export const SMAPI_TOKEN_VERSION = "1"; +export const SMAPI_TOKEN_VERSION = 2; export class JWTSmapiLoginTokens implements SmapiAuthTokens { private readonly clock: Clock; private readonly secret: string; private readonly expiresIn: string; - private readonly version: string; + private readonly version: number; private readonly keyGenerator: () => string; - constructor(clock: Clock, secret: string, expiresIn: string, keyGenerator: () => string = uuid, version: string = SMAPI_TOKEN_VERSION) { + constructor(clock: Clock, secret: string, expiresIn: string, keyGenerator: () => string = uuid, version: number = SMAPI_TOKEN_VERSION) { this.clock = clock; this.secret = secret; this.expiresIn = expiresIn; diff --git a/src/subsonic.ts b/src/subsonic.ts index f4afe28..6bded74 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -208,6 +208,13 @@ type GetStarredResponse = { }; }; +export type PingResponse = { + status: string, + version: string, + type: string, + serverVersion: string +} + type Search3Response = SubsonicResponse & { searchResult3: { artist: artist[]; @@ -383,6 +390,16 @@ const AlbumQueryTypeToSubsonicType: Record = { const artistIsInLibrary = (artistId: string | undefined) => artistId != undefined && artistId != "-1"; +type SubsonicCredentials = Credentials & { type: string, bearer: string | undefined } + +export const asToken = (credentials: SubsonicCredentials) => b64Encode(JSON.stringify(credentials)) +export const parseToken = (token: string): SubsonicCredentials => JSON.parse(b64Decode(token)); + +interface SubsonicMusicLibrary extends MusicLibrary { + flavour(): string + bearerToken(): Promise +} + export class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; @@ -441,16 +458,16 @@ export class Subsonic implements MusicService { }); generateToken = async (credentials: Credentials) => - this.getJSON(credentials, "/rest/ping.view") - .then(() => ({ - serviceToken: b64Encode(JSON.stringify(credentials)), + this.getJSON(credentials, "/rest/ping.view") + .then(({ type }) => this.libraryFor({ ...credentials, type }).then(library => ({ type, library }))) + .then(({ library, type }) => library.bearerToken().then(bearer => ({ bearer, type }))) + .then(({ bearer, type }) => ({ + serviceToken: asToken({ ...credentials, bearer, type }), userId: credentials.username, nickname: credentials.username, })) .catch((e) => ({ message: `${e}` })); - parseToken = (token: string): Credentials => JSON.parse(b64Decode(token)); - getArtists = ( credentials: Credentials ): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => @@ -622,11 +639,14 @@ export class Subsonic implements MusicService { // albums: it.album.map(asAlbum), // })); - async login(token: string) { - const subsonic = this; - const credentials: Credentials = this.parseToken(token); + login = async (token: string) => this.libraryFor(parseToken(token)) - const musicLibrary: MusicLibrary = { + private libraryFor = (credentials: Credentials & { type: string }) => { + const subsonic = this; + + const genericSubsonic: SubsonicMusicLibrary = { + flavour: () => "subsonic", + bearerToken: () => Promise.resolve(undefined), artists: (q: ArtistQuery): Promise> => subsonic .getArtists(credentials) @@ -903,6 +923,13 @@ export class Subsonic implements MusicService { ), }; - return Promise.resolve(musicLibrary); + if(credentials.type == "navidrome") { + return Promise.resolve({ + ...genericSubsonic, + flavour: ()=> "navidrome" + }) + } else { + return Promise.resolve(genericSubsonic); + } } } diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 49c62fb..5ccac21 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -44,6 +44,7 @@ export class InMemoryMusicService implements MusicService { serviceToken: b64Encode(JSON.stringify({ username, password })), userId: username, nickname: username, + type: "in-memory" }); } else { return Promise.resolve({ message: `Invalid user:${username}` }); diff --git a/tests/smapi_auth.test.ts b/tests/smapi_auth.test.ts index f824344..b3f518c 100644 --- a/tests/smapi_auth.test.ts +++ b/tests/smapi_auth.test.ts @@ -96,26 +96,26 @@ describe("auth", () => { it("should return an error", () => { const authToken = uuid(); - const v1SmapiTokens = new JWTSmapiLoginTokens( + const vXSmapiTokens = new JWTSmapiLoginTokens( clock, secret, expiresIn, () => uuid(), - "1" + SMAPI_TOKEN_VERSION ); - const v2SmapiTokens = new JWTSmapiLoginTokens( + const vXPlus1SmapiTokens = new JWTSmapiLoginTokens( clock, secret, expiresIn, () => uuid(), - "2" + SMAPI_TOKEN_VERSION + 1 ); - const v1Token = v1SmapiTokens.issue(authToken); - expect(v1SmapiTokens.verify(v1Token)).toEqual(E.right(authToken)); + const v1Token = vXSmapiTokens.issue(authToken); + expect(vXSmapiTokens.verify(v1Token)).toEqual(E.right(authToken)); - const result = v2SmapiTokens.verify(v1Token); + const result = vXPlus1SmapiTokens.verify(v1Token); expect(result).toEqual( E.left(new InvalidTokenError("invalid signature")) ); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index cb1ac38..77f38c9 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -19,6 +19,9 @@ import { artistImageURN, images, song, + PingResponse, + parseToken, + asToken, } from "../src/subsonic"; import axios from "axios"; @@ -558,7 +561,19 @@ const FAILURE = { }, }; -const PING_OK = subsonicOK({}); + + +const pingJson = (pingResponse: Partial = {}) => ({ + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "navidrome", + serverVersion: "0.45.1 (c55e6590)", + ...pingResponse + } +}) + +const PING_OK = pingJson({ status: "ok" }); describe("artistURN", () => { describe("when artist URL is", () => { @@ -729,6 +744,29 @@ describe("Subsonic", () => { expect(token.nickname).toEqual(username); expect(token.userId).toEqual(username); + expect(parseToken(token.serviceToken)).toEqual({ username, password, type: PING_OK["subsonic-response"].type }) + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + + it("should store the type of the subsonic server on the token", async () => { + const type = "someSubsonicClone"; + (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); + + const token = (await navidrome.generateToken({ + username, + password, + })) as AuthSuccess; + + expect(token.serviceToken).toBeDefined(); + expect(token.nickname).toEqual(username); + expect(token.userId).toEqual(username); + + expect(parseToken(token.serviceToken)).toEqual({ username, password, type }) + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { params: asURLSearchParams(authParamsPlusJson), headers, @@ -751,6 +789,29 @@ describe("Subsonic", () => { }); }); + describe("login", () => { + describe("when the token is for generic subsonic", () => { + it("should return a subsonic client", async () => { + const client = await navidrome.login(asToken({ username: "foo", password: "bar", type: "subsonic", bearer: undefined })); + expect(client.flavour()).toEqual("subsonic"); + }); + }); + + describe("when the token is for navidrome", () => { + it("should return a navidrome client", async () => { + const client = await navidrome.login(asToken({ username: "foo", password: "bar", type: "navidrome", bearer: undefined })); + expect(client.flavour()).toEqual("navidrome"); + }); + }); + + describe("when the token is for gonic", () => { + it("should return a subsonic client", async () => { + const client = await navidrome.login(asToken({ username: "foo", password: "bar", type: "gonic", bearer: undefined })); + expect(client.flavour()).toEqual("subsonic"); + }); + }); + }); + describe("getting genres", () => { describe("when there are none", () => { beforeEach(() => {