mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-22 09:53:32 +01:00
Query for genres
This commit is contained in:
@@ -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[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +271,15 @@ 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", {
|
||||||
...p,
|
...p,
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -379,7 +383,7 @@ describe("InMemoryMusicService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("can fetch the last page", () => {
|
describe("can fetch the last page", () => {
|
||||||
it("should return only the albums for the last page", async () => {
|
it("should return only the albums for the last page", async () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -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"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,30 +424,30 @@ 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],
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should pass the filter to navidrome", async () => {
|
it("should pass the filter to navidrome", async () => {
|
||||||
const q = { _index: 0, _count: 500, genre: "Pop" };
|
const q = { _index: 0, _count: 500, genre: "Pop" };
|
||||||
const result = await navidrome
|
const result = await navidrome
|
||||||
@@ -417,12 +455,12 @@ describe("Navidrome", () => {
|
|||||||
.then((it) => it as AuthSuccess)
|
.then((it) => it as AuthSuccess)
|
||||||
.then((it) => navidrome.login(it.authToken))
|
.then((it) => navidrome.login(it.authToken))
|
||||||
.then((it) => it.albums(q));
|
.then((it) => it.albums(q));
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
results: [album1, album3].map(albumToAlbumSummary),
|
results: [album1, album3].map(albumToAlbumSummary),
|
||||||
total: 2,
|
total: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, {
|
||||||
params: {
|
params: {
|
||||||
type: "byGenre",
|
type: "byGenre",
|
||||||
@@ -434,7 +472,6 @@ describe("Navidrome", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when there are less than 500 albums", () => {
|
describe("when there are less than 500 albums", () => {
|
||||||
@@ -505,7 +542,7 @@ describe("Navidrome", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when there are more than 500 albums", () => {
|
describe("when there are more than 500 albums", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user