Ability to browse Random Albums

This commit is contained in:
simojenki
2021-03-23 10:42:49 +11:00
parent 8f5905c16f
commit 4730511a84
8 changed files with 185 additions and 89 deletions

View File

@@ -6,6 +6,14 @@ In theory as Navidrome implements the subsonic API, it *may* work with other sub
![Build](https://github.com/simojenki/bonob/workflows/Build/badge.svg)
## Features
- Integrates with Navidrome
- Browse by Artist, Albums, Genres, Random
- Artist Art
- Album Art
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
- Track scrobbling
## Running
bonob is ditributed via docker and can be run in a number of ways

View File

@@ -94,7 +94,10 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
export type ArtistQuery = Paging;
export type AlbumQueryType = 'alphabeticalByArtist' | 'byGenre' | 'random' | 'recent' | 'frequent';
export type AlbumQuery = Paging & {
type: AlbumQueryType;
genre?: string;
};

View File

@@ -21,11 +21,11 @@ import {
} from "./music_service";
import X2JS from "x2js";
import sharp from "sharp";
import { pick } from "underscore";
import axios, { AxiosRequestConfig } from "axios";
import { Encryption } from "./encryption";
import randomString from "./random_string";
import { fold } from "fp-ts/lib/Option";
export const BROWSER_HEADERS = {
accept:
@@ -391,17 +391,9 @@ export class Navidrome implements MusicService {
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
navidrome
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
...fold(
() => ({
type: "alphabeticalByArtist",
}),
(genre) => ({
type: "byGenre",
genre,
})
)(O.fromNullable(q.genre)),
size: MAX_ALBUM_LIST,
offset: 0,
...pick(q, 'type', 'genre'),
size: Math.min(MAX_ALBUM_LIST, q._count),
offset: q._index,
})
.then((response) => response.albumList.album || [])
.then((albumList) =>

View File

@@ -449,9 +449,10 @@ function bindSmapiSoapServiceToExpress(
container({ id: "artists", title: "Artists" }),
container({ id: "albums", title: "Albums" }),
container({ id: "genres", title: "Genres" }),
container({ id: "randomAlbums", title: "Random" }),
],
index: 0,
total: 3,
total: 4,
});
case "artists":
return await musicLibrary.artists(paging).then((result) => {
@@ -465,16 +466,31 @@ function bindSmapiSoapServiceToExpress(
});
});
case "albums":
return await musicLibrary.albums(paging).then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) =>
album(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
return await musicLibrary
.albums({ type: "alphabeticalByArtist", ...paging })
.then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) =>
album(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
});
});
case "randomAlbums":
return await musicLibrary
.albums({ type: "random", ...paging })
.then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) =>
album(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
});
});
});
case "genres":
return await musicLibrary
.genres()
@@ -530,18 +546,20 @@ function bindSmapiSoapServiceToExpress(
total,
});
});
case "genre":
return await musicLibrary.albums({ ...paging, genre: typeId }).then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) =>
album(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
});
case "genre":
return await musicLibrary
.albums({ type: "byGenre", genre: typeId, ...paging })
.then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) =>
album(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
});
default:
});
default:
throw `Unsupported id:${id}`;
}
},

View File

@@ -6,7 +6,16 @@ import {
albumToAlbumSummary,
} from "../src/music_service";
import { v4 as uuid } from "uuid";
import { anArtist, anAlbum, aTrack, POP, ROCK, METAL, HIP_HOP, SKA } from "./builders";
import {
anArtist,
anAlbum,
aTrack,
POP,
ROCK,
METAL,
HIP_HOP,
SKA,
} from "./builders";
describe("InMemoryMusicService", () => {
const service = new InMemoryMusicService();
@@ -147,7 +156,6 @@ describe("InMemoryMusicService", () => {
});
});
describe("tracks", () => {
const artist1Album1 = anAlbum();
const artist1Album2 = anAlbum();
@@ -201,8 +209,6 @@ describe("InMemoryMusicService", () => {
const artist3_album1 = anAlbum({ genre: HIP_HOP });
const artist3_album2 = anAlbum({ genre: POP });
const totalAlbumCount = 8;
const artist1 = anArtist({
albums: [
artist1_album1,
@@ -216,16 +222,56 @@ describe("InMemoryMusicService", () => {
const artist3 = anArtist({ albums: [artist3_album1, artist3_album2] });
const artistWithNoAlbums = anArtist({ albums: [] });
const allAlbums = [artist1, artist2, artist3, artistWithNoAlbums].flatMap(
(it) => it.albums
);
const totalAlbumCount = allAlbums.length;
beforeEach(() => {
service.hasArtists(artist1, artist2, artist3, artistWithNoAlbums);
});
describe("fetching random albums", () => {
describe("with no paging", () => {
it("should return all albums for all the artists in a random order", async () => {
const albums = await musicLibrary.albums({
_index: 0,
_count: 100,
type: "random",
});
expect(albums.total).toEqual(totalAlbumCount);
expect(albums.results.map((it) => it.id).sort()).toEqual(
allAlbums.map((it) => it.id).sort()
);
});
});
describe("with no paging", () => {
it("should return only a page of results", async () => {
const albums = await musicLibrary.albums({
_index: 2,
_count: 3,
type: "random",
});
expect(albums.total).toEqual(totalAlbumCount);
expect(albums.results.length).toEqual(3)
// cannot really assert the results and they will change every time
});
});
});
describe("fetching multiple albums", () => {
describe("with no filtering", () => {
describe("fetching all on one page", () => {
it("should return all the albums for all the artists", async () => {
expect(
await musicLibrary.albums({ _index: 0, _count: 100 })
await musicLibrary.albums({
_index: 0,
_count: 100,
type: "alphabeticalByArtist",
})
).toEqual({
results: [
albumToAlbumSummary(artist1_album1),
@@ -233,9 +279,9 @@ describe("InMemoryMusicService", () => {
albumToAlbumSummary(artist1_album3),
albumToAlbumSummary(artist1_album4),
albumToAlbumSummary(artist1_album5),
albumToAlbumSummary(artist2_album1),
albumToAlbumSummary(artist3_album1),
albumToAlbumSummary(artist3_album2),
],
@@ -243,26 +289,34 @@ describe("InMemoryMusicService", () => {
});
});
});
describe("fetching a page", () => {
it("should return only that page", async () => {
expect(await musicLibrary.albums({ _index: 4, _count: 3 })).toEqual(
{
results: [
albumToAlbumSummary(artist1_album5),
albumToAlbumSummary(artist2_album1),
albumToAlbumSummary(artist3_album1),
],
total: totalAlbumCount,
}
);
expect(
await musicLibrary.albums({
_index: 4,
_count: 3,
type: "alphabeticalByArtist",
})
).toEqual({
results: [
albumToAlbumSummary(artist1_album5),
albumToAlbumSummary(artist2_album1),
albumToAlbumSummary(artist3_album1),
],
total: totalAlbumCount,
});
});
});
describe("fetching the last page", () => {
it("should return only that page", async () => {
expect(
await musicLibrary.albums({ _index: 6, _count: 100 })
await musicLibrary.albums({
_index: 6,
_count: 100,
type: "alphabeticalByArtist",
})
).toEqual({
results: [
albumToAlbumSummary(artist3_album1),
@@ -273,12 +327,13 @@ describe("InMemoryMusicService", () => {
});
});
});
describe("filtering by genre", () => {
describe("fetching all on one page", () => {
it("should return all the albums of that genre for all the artists", async () => {
expect(
await musicLibrary.albums({
type: "byGenre",
genre: POP.id,
_index: 0,
_count: 100,
@@ -294,12 +349,13 @@ describe("InMemoryMusicService", () => {
});
});
});
describe("when the genre has more albums than a single page", () => {
describe("can fetch a single page", () => {
it("should return only the albums for that page", async () => {
expect(
await musicLibrary.albums({
type: "byGenre",
genre: POP.id,
_index: 1,
_count: 2,
@@ -313,11 +369,12 @@ describe("InMemoryMusicService", () => {
});
});
});
describe("can fetch the last page", () => {
it("should return only the albums for the last page", async () => {
expect(
await musicLibrary.albums({
type: "byGenre",
genre: POP.id,
_index: 3,
_count: 100,
@@ -329,10 +386,11 @@ describe("InMemoryMusicService", () => {
});
});
});
it("should return empty list if there are no albums for the genre", async () => {
expect(
await musicLibrary.albums({
type: "byGenre",
genre: "genre with no albums",
_index: 0,
_count: 100,
@@ -353,7 +411,7 @@ describe("InMemoryMusicService", () => {
);
});
});
describe("when it doesnt exist", () => {
it("should blow up", async () => {
return expect(musicLibrary.album("-1")).rejects.toEqual(

View File

@@ -3,6 +3,7 @@ import * as A from "fp-ts/Array";
import { fromEquals } from "fp-ts/lib/Eq";
import { pipe } from "fp-ts/lib/function";
import { ordString, fromCompare } from "fp-ts/lib/Ord";
import { shuffle } from "underscore";
import {
MusicService,
@@ -17,17 +18,10 @@ import {
asResult,
artistToArtistSummary,
albumToAlbumSummary,
Album,
Track,
Genre,
} from "../src/music_service";
type P<T> = (t: T) => boolean;
const all: P<any> = (_: any) => true;
const albumWithGenre = (genreId: string): P<[Artist, Album]> => ([_, album]) =>
album.genre?.id === genreId;
export class InMemoryMusicService implements MusicService {
users: Record<string, string> = {};
artists: Artist[] = [];
@@ -75,17 +69,25 @@ export class InMemoryMusicService implements MusicService {
),
albums: (q: AlbumQuery) =>
Promise.resolve(
this.artists
.flatMap((artist) => artist.albums.map((album) => [artist, album]))
.filter(
pipe(
O.fromNullable(q.genre),
O.map(albumWithGenre),
O.getOrElse(() => all)
)
)
this.artists.flatMap((artist) =>
artist.albums.map((album) => ({ artist, album }))
)
)
.then((matches) => matches.map(([_, album]) => album as Album))
.then((artist2Album) => {
switch (q.type) {
case "alphabeticalByArtist":
return artist2Album;
case "byGenre":
return artist2Album.filter(
(it) => it.album.genre?.id === q.genre
);
case "random":
return shuffle(artist2Album);
default:
return [];
}
})
.then((matches) => matches.map((it) => it.album))
.then((it) => it.map(albumToAlbumSummary))
.then(slice2(q))
.then(asResult),

View File

@@ -30,6 +30,7 @@ import {
AlbumSummary,
artistToArtistSummary,
NO_IMAGES,
AlbumQuery,
} from "../src/music_service";
import { anAlbum, anArtist, aTrack } from "./builders";
@@ -867,7 +868,7 @@ describe("Navidrome", () => {
});
it("should pass the filter to navidrome", async () => {
const q = { _index: 0, _count: 500, genre: "Pop" };
const q: AlbumQuery = { _index: 0, _count: 500, genre: "Pop", type: 'byGenre' };
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
@@ -910,12 +911,12 @@ describe("Navidrome", () => {
});
it("should return the album", async () => {
const paging = { _index: 0, _count: 500 };
const q: AlbumQuery = { _index: 0, _count: 500, type: 'alphabeticalByArtist' };
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.albums(paging));
.then((it) => it.albums(q));
expect(result).toEqual({
results: albums,
@@ -951,12 +952,12 @@ describe("Navidrome", () => {
});
it("should return the album", async () => {
const paging = { _index: 0, _count: 500 };
const q: AlbumQuery = { _index: 0, _count: 500, type: 'alphabeticalByArtist' };
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.albums(paging));
.then((it) => it.albums(q));
expect(result).toEqual({
results: albums,
@@ -1001,12 +1002,12 @@ describe("Navidrome", () => {
describe("querying for all of them", () => {
it("should return all of them with corrent paging information", async () => {
const paging = { _index: 0, _count: 500 };
const q : AlbumQuery= { _index: 0, _count: 500, type: 'alphabeticalByArtist' };
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.albums(paging));
.then((it) => it.albums(q));
expect(result).toEqual({
results: albums,
@@ -1027,12 +1028,12 @@ describe("Navidrome", () => {
describe("querying for a page of them", () => {
it("should return the page with the corrent paging information", async () => {
const paging = { _index: 2, _count: 2 };
const q : AlbumQuery = { _index: 2, _count: 2, type: 'alphabeticalByArtist' };
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.albums(paging));
.then((it) => it.albums(q));
expect(result).toEqual({
results: [albums[2], albums[3]],
@@ -1042,8 +1043,8 @@ describe("Navidrome", () => {
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, {
params: {
type: "alphabeticalByArtist",
size: 500,
offset: 0,
size: 2,
offset: 2,
...authParams,
},
headers,
@@ -1079,12 +1080,12 @@ describe("Navidrome", () => {
describe("querying for all of them", () => {
it("will return only the first 500 with the correct paging information", async () => {
const paging = { _index: 0, _count: 1000 };
const q: AlbumQuery = { _index: 0, _count: 1000, type: 'alphabeticalByArtist' };
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.albums(paging));
.then((it) => it.albums(q));
expect(result).toEqual({
results: first500Albums.map(albumToAlbumSummary),

View File

@@ -556,9 +556,10 @@ describe("api", () => {
{ itemType: "container", id: "artists", title: "Artists" },
{ itemType: "container", id: "albums", title: "Albums" },
{ itemType: "container", id: "genres", title: "Genres" },
{ itemType: "container", id: "randomAlbums", title: "Random" },
],
index: 0,
total: 3,
total: 4,
})
);
});
@@ -889,6 +890,19 @@ describe("api", () => {
musicService.hasArtists(artist1, artist2, artist3, artist4);
});
describe("asking for random albums", () => {
it("should return some", async () => {
const result = await ws.getMetadataAsync({
id: "randomAlbums",
index: 0,
count: 100,
});
expect(result[0].getMetadataResult.index).toEqual(0);
expect(result[0].getMetadataResult.count).toEqual(6);
expect(result[0].getMetadataResult.total).toEqual(6);
});
});
describe("asking for all albums", () => {
it("should return them all", async () => {
const result = await ws.getMetadataAsync({