Query for genres

This commit is contained in:
simojenki
2021-03-07 09:14:07 +11:00
parent 1e5d020a75
commit c5a085d667
5 changed files with 157 additions and 56 deletions

View File

@@ -25,17 +25,17 @@ export type AuthFailure = {
export type ArtistSummary = { export type ArtistSummary = {
id: string; id: string;
name: string; name: string;
image: Images image: Images;
} };
export type Images = { export type Images = {
small: string | undefined, small: string | undefined;
medium: string | undefined, medium: string | undefined;
large: string | undefined, large: string | undefined;
} };
export type Artist = ArtistSummary & { export type Artist = ArtistSummary & {
albums: Album[] albums: Album[];
}; };
export type AlbumSummary = { export type AlbumSummary = {
@@ -43,11 +43,10 @@ export type AlbumSummary = {
name: string; name: string;
year: string | undefined; year: string | undefined;
genre: string | undefined; genre: string | undefined;
}
export type Album = AlbumSummary & {
}; };
export type Album = AlbumSummary & {};
export type Paging = { export type Paging = {
_index: number; _index: number;
_count: number; _count: number;
@@ -70,30 +69,33 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
total, total,
}); });
export type ArtistQuery = Paging export type ArtistQuery = Paging;
export type AlbumQuery = Paging & { export type AlbumQuery = Paging & {
artistId?: string artistId?: string;
genre?: string genre?: string;
} };
export const artistToArtistSummary = ( export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
it: Artist
): ArtistSummary => ({
id: it.id, id: it.id,
name: it.name, name: it.name,
image: it.image, image: it.image,
}); });
export const albumToAlbumSummary = ( export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
it: Album
): AlbumSummary => ({
id: it.id, id: it.id,
name: it.name, name: it.name,
year: it.year, year: it.year,
genre: it.genre, genre: it.genre,
}); });
export const range = (size: number) => [...Array(size).keys()];
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
artists.flatMap((artist) =>
artist.albums.map((album) => [artist, album] as [Artist, Album])
);
export interface MusicService { export interface MusicService {
generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>; generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>;
login(authToken: string): Promise<MusicLibrary>; login(authToken: string): Promise<MusicLibrary>;
@@ -104,4 +106,5 @@ export interface MusicLibrary {
artist(id: string): Promise<Artist>; artist(id: string): Promise<Artist>;
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>; albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
album(id: string): Promise<Album>; album(id: string): Promise<Album>;
genres(): Promise<string[]>;
} }

View File

@@ -1,4 +1,6 @@
import { option as O } from "fp-ts"; import { option as O } from "fp-ts";
import * as A from "fp-ts/Array";
import { ordString } from "fp-ts/lib/Ord";
import { pipe } from "fp-ts/lib/function"; import { pipe } from "fp-ts/lib/function";
import { Md5 } from "ts-md5/dist/md5"; import { Md5 } from "ts-md5/dist/md5";
import { import {
@@ -21,7 +23,6 @@ import axios from "axios";
import { Encryption } from "./encryption"; import { Encryption } from "./encryption";
import randomString from "./random_string"; import randomString from "./random_string";
export const t = (password: string, s: string) => export const t = (password: string, s: string) =>
Md5.hashStr(`${password}${s}`); Md5.hashStr(`${password}${s}`);
@@ -75,6 +76,18 @@ export type GetAlbumListResponse = SubsonicResponse & {
}; };
}; };
export type genre = {
_songCount: string;
_albumCount: string;
__text: string;
};
export type GenGenresResponse = SubsonicResponse & {
genres: {
genre: genre[];
};
};
export type SubsonicError = SubsonicResponse & { export type SubsonicError = SubsonicResponse & {
error: { error: {
_code: string; _code: string;
@@ -115,13 +128,13 @@ export type IdName = {
}; };
export type getAlbumListParams = { export type getAlbumListParams = {
type: string, type: string;
size?: number; size?: number;
offet?: number; offet?: number;
fromYear?: string, fromYear?: string;
toYear?: string, toYear?: string;
genre?: string genre?: string;
} };
const MAX_ALBUM_LIST = 500; const MAX_ALBUM_LIST = 500;
@@ -258,9 +271,14 @@ export class Navidrome implements MusicService {
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> => { albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> => {
const p = pipe( const p = pipe(
O.fromNullable(q.genre), O.fromNullable(q.genre),
O.map<string, getAlbumListParams>(genre => ({ type: "byGenre", genre })), O.map<string, getAlbumListParams>((genre) => ({
O.getOrElse<getAlbumListParams>(() => ({ type: "alphabeticalByArtist" })), type: "byGenre",
) genre,
})),
O.getOrElse<getAlbumListParams>(() => ({
type: "alphabeticalByArtist",
}))
);
return navidrome return navidrome
.get<GetAlbumListResponse>(credentials, "/rest/getAlbumList", { .get<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
@@ -286,6 +304,14 @@ export class Navidrome implements MusicService {
album: (_: string): Promise<Album> => { album: (_: string): Promise<Album> => {
return Promise.reject("not implemented"); return Promise.reject("not implemented");
}, },
genres: () =>
navidrome
.get<GenGenresResponse>(credentials, "/rest/getGenres")
.then((it) => pipe(
it.genres.genre,
A.map(it => it.__text),
A.sort(ordString)
)),
}; };
return Promise.resolve(musicLibrary); return Promise.resolve(musicLibrary);

View File

@@ -346,7 +346,11 @@ describe("InMemoryMusicService", () => {
describe("fetching all on one page", () => { describe("fetching all on one page", () => {
it.only("should return all the albums of that genre for all the artists", async () => { it.only("should return all the albums of that genre for all the artists", async () => {
expect( expect(
await musicLibrary.albums({ _index: 0, _count: 100, genre: "Pop" }) await musicLibrary.albums({
_index: 0,
_count: 100,
genre: "Pop",
})
).toEqual({ ).toEqual({
results: [ results: [
albumToAlbumSummary(artist1_album1), albumToAlbumSummary(artist1_album1),
@@ -394,7 +398,6 @@ describe("InMemoryMusicService", () => {
}); });
}); });
}); });
}); });
it("should return empty list if there are no albums for the genre", async () => { it("should return empty list if there are no albums for the genre", async () => {
@@ -411,5 +414,27 @@ describe("InMemoryMusicService", () => {
}); });
}); });
}); });
describe("genres", () => {
const artist1 = anArtist({ albums: [anAlbum({ genre: "Pop" }), anAlbum({ genre: "Rock" }), anAlbum({ genre: "Pop" })] });
const artist2 = anArtist({ albums: [anAlbum({ genre: "Hip-Hop" }), anAlbum({ genre: "Rap" }), anAlbum({ genre: "Pop" })] });
beforeEach(() => {
service.hasArtists(artist1, artist2);
});
describe("fetching all in one page", () => {
it("should provide an array of artists", async () => {
expect(
await musicLibrary.genres()
).toEqual([
"Hip-Hop",
"Pop",
"Rap",
"Rock"
]);
});
});
});
}); });
}); });

View File

@@ -1,5 +1,8 @@
import { option as O } from "fp-ts"; import { option as O } from "fp-ts";
import * as A from "fp-ts/Array";
import { eqString } from "fp-ts/lib/Eq";
import { pipe } from "fp-ts/lib/function"; import { pipe } from "fp-ts/lib/function";
import { ordString } from "fp-ts/lib/Ord";
import { import {
MusicService, MusicService,
@@ -53,6 +56,7 @@ export class InMemoryMusicService implements MusicService {
const credentials = JSON.parse(token) as Credentials; const credentials = JSON.parse(token) as Credentials;
if (this.users[credentials.username] != credentials.password) if (this.users[credentials.username] != credentials.password)
return Promise.reject("Invalid auth token"); return Promise.reject("Invalid auth token");
return Promise.resolve({ return Promise.resolve({
artists: (q: ArtistQuery) => artists: (q: ArtistQuery) =>
Promise.resolve(this.artists.map(artistToArtistSummary)) Promise.resolve(this.artists.map(artistToArtistSummary))
@@ -71,15 +75,9 @@ export class InMemoryMusicService implements MusicService {
.flatMap((artist) => artist.albums.map((album) => [artist, album])) .flatMap((artist) => artist.albums.map((album) => [artist, album]))
.filter( .filter(
pipe( pipe(
pipe( pipe(O.fromNullable(q.artistId), O.map(albumByArtist)),
O.fromNullable(q.artistId),
O.map(albumByArtist)
),
O.alt(() => O.alt(() =>
pipe( pipe(O.fromNullable(q.genre), O.map(albumWithGenre))
O.fromNullable(q.genre),
O.map(albumWithGenre)
)
), ),
O.getOrElse(() => all) O.getOrElse(() => all)
) )
@@ -96,6 +94,18 @@ export class InMemoryMusicService implements MusicService {
O.map((it) => Promise.resolve(it)), O.map((it) => Promise.resolve(it)),
O.getOrElse(() => Promise.reject(`No album with id '${id}'`)) O.getOrElse(() => Promise.reject(`No album with id '${id}'`))
), ),
genres: () =>
Promise.resolve(
pipe(
this.artists,
A.map((it) => it.albums),
A.flatten,
A.map((it) => O.fromNullable(it.genre)),
A.compact,
A.uniq(eqString),
A.sort(ordString)
)
),
}); });
} }

View File

@@ -13,6 +13,8 @@ import {
AuthSuccess, AuthSuccess,
Images, Images,
albumToAlbumSummary, albumToAlbumSummary,
range,
asArtistAlbumPairs,
} from "../src/music_service"; } from "../src/music_service";
import { anAlbum, anArtist } from "./builders"; import { anAlbum, anArtist } from "./builders";
@@ -99,6 +101,17 @@ const artistXml = (
</artist> </artist>
</subsonic-response>`; </subsonic-response>`;
const genresXml = (
genres: string[]
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<genres>
${genres.map(
(it) =>
`<genre songCount="1475" albumCount="86">${it}</genre>`
)}
</genres>
</subsonic-response>`;
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`; const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
describe("Navidrome", () => { describe("Navidrome", () => {
@@ -163,6 +176,31 @@ describe("Navidrome", () => {
}); });
}); });
describe("getting genres", () => {
const genres = ["HipHop", "Rap", "TripHop", "Pop", "Rock"];
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(genresXml(genres))));
});
it.only("should return them alphabetically sorted", async () => {
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.genres());
expect(result).toEqual(genres.sort());
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, {
params: {
...authParams,
},
});
});
});
describe("getting an artist", () => { describe("getting an artist", () => {
const album1: Album = anAlbum(); const album1: Album = anAlbum();
@@ -386,27 +424,27 @@ describe("Navidrome", () => {
}); });
}); });
const range = (size: number) => [...Array(size).keys()];
const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
artists.flatMap((artist) =>
artist.albums.map((album) => [artist, album] as [Artist, Album])
);
describe("getting albums", () => { describe("getting albums", () => {
describe("filtering", () => { describe("filtering", () => {
const album1 = anAlbum({ genre: "Pop" }); const album1 = anAlbum({ genre: "Pop" });
const album2 = anAlbum({ genre: "Rock" }); const album2 = anAlbum({ genre: "Rock" });
const album3 = anAlbum({ genre: "Pop" }); const album3 = anAlbum({ genre: "Pop" });
const artist = anArtist({ albums: [album1, album2, album3]}); const artist = anArtist({ albums: [album1, album2, album3] });
describe("by genre", () => { describe("by genre", () => {
beforeEach(() => { beforeEach(() => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(albumListXml([[artist, album1], [artist, album3]]))) Promise.resolve(
ok(
albumListXml([
[artist, album1],
[artist, album3],
])
)
)
); );
}); });
@@ -434,7 +472,6 @@ describe("Navidrome", () => {
}); });
}); });
}); });
}); });
describe("when there are less than 500 albums", () => { describe("when there are less than 500 albums", () => {