diff --git a/README.md b/README.md index e2a808c..6ce574c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/music_service.ts b/src/music_service.ts index cbb84b4..fc666f0 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -94,7 +94,10 @@ export const asResult = ([results, total]: [T[], number]) => ({ export type ArtistQuery = Paging; +export type AlbumQueryType = 'alphabeticalByArtist' | 'byGenre' | 'random' | 'recent' | 'frequent'; + export type AlbumQuery = Paging & { + type: AlbumQueryType; genre?: string; }; diff --git a/src/navidrome.ts b/src/navidrome.ts index 0f9df46..9b26f82 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -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> => navidrome .getJSON(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) => diff --git a/src/smapi.ts b/src/smapi.ts index fbec5cf..68cb851 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -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}`; } }, diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index a5517f9..64221af 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -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( diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index ed05e5e..760d8b9 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -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) => boolean; -const all: P = (_: any) => true; - -const albumWithGenre = (genreId: string): P<[Artist, Album]> => ([_, album]) => - album.genre?.id === genreId; - export class InMemoryMusicService implements MusicService { users: Record = {}; 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), diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 2a32d56..bc3a31a 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -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), diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 00e91cc..6622c7e 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -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({