mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-22 01:43:29 +01:00
Ability to search by artist, album, track
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import crypto from "crypto";
|
||||
|
||||
import { Encryption } from "./encryption";
|
||||
import logger from "./logger";
|
||||
|
||||
export interface Clock {
|
||||
now(): Dayjs;
|
||||
}
|
||||
import { Clock, SystemClock } from "./clock";
|
||||
|
||||
type AccessToken = {
|
||||
value: string;
|
||||
@@ -24,7 +21,7 @@ export class ExpiringAccessTokens implements AccessTokens {
|
||||
tokens = new Map<string, AccessToken>();
|
||||
clock: Clock;
|
||||
|
||||
constructor(clock: Clock = { now: () => dayjs() }) {
|
||||
constructor(clock: Clock = SystemClock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
7
src/clock.ts
Normal file
7
src/clock.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
|
||||
export interface Clock {
|
||||
now(): Dayjs;
|
||||
}
|
||||
|
||||
export const SystemClock = { now: () => dayjs() };
|
||||
@@ -155,4 +155,7 @@ export interface MusicLibrary {
|
||||
}): Promise<TrackStream>;
|
||||
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
|
||||
scrobble(id: string): Promise<boolean>
|
||||
searchArtists(query: string): Promise<ArtistSummary[]>;
|
||||
searchAlbums(query: string): Promise<AlbumSummary[]>;
|
||||
searchTracks(query: string): Promise<Track[]>;
|
||||
}
|
||||
|
||||
@@ -166,6 +166,14 @@ export type GetSongResponse = {
|
||||
song: song;
|
||||
};
|
||||
|
||||
export type Search3Response = SubsonicResponse & {
|
||||
searchResult3: {
|
||||
artist: artistSummary[];
|
||||
album: album[];
|
||||
song: song[];
|
||||
};
|
||||
};
|
||||
|
||||
export function isError(
|
||||
subsonicResponse: SubsonicResponse
|
||||
): subsonicResponse is SubsonicError {
|
||||
@@ -270,9 +278,9 @@ export class Navidrome implements MusicService {
|
||||
...config,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status != 200 && response.status != 206)
|
||||
throw `Navidrome failed with a ${response.status}`;
|
||||
else return response;
|
||||
if (response.status != 200 && response.status != 206) {
|
||||
throw `Navidrome failed with a ${response.status || "no!"} status`;
|
||||
} else return response;
|
||||
});
|
||||
|
||||
getJSON = async <T>(
|
||||
@@ -290,6 +298,9 @@ export class Navidrome implements MusicService {
|
||||
"subsonic-response.album.song",
|
||||
"subsonic-response.genres.genre",
|
||||
"subsonic-response.artistInfo.similarArtist",
|
||||
"subsonic-response.searchResult3.artist",
|
||||
"subsonic-response.searchResult3.album",
|
||||
"subsonic-response.searchResult3.song",
|
||||
],
|
||||
}).xml2js(response.data) as SubconicEnvelope
|
||||
)
|
||||
@@ -405,6 +416,26 @@ export class Navidrome implements MusicService {
|
||||
)
|
||||
);
|
||||
|
||||
toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
|
||||
albumList.map((album) => ({
|
||||
id: album._id,
|
||||
name: album._name,
|
||||
year: album._year,
|
||||
genre: maybeAsGenre(album._genre),
|
||||
}));
|
||||
|
||||
search3 = (credentials: Credentials, q: any) =>
|
||||
this.getJSON<Search3Response>(credentials, "/rest/search3", {
|
||||
artistCount: 0,
|
||||
albumCount: 0,
|
||||
songCount: 0,
|
||||
...q,
|
||||
}).then((it) => ({
|
||||
artists: it.searchResult3.artist || [],
|
||||
albums: it.searchResult3.album || [],
|
||||
songs: it.searchResult3.song || [],
|
||||
}));
|
||||
|
||||
async login(token: string) {
|
||||
const navidrome = this;
|
||||
const credentials: Credentials = this.parseToken(token);
|
||||
@@ -428,14 +459,7 @@ export class Navidrome implements MusicService {
|
||||
offset: q._index,
|
||||
})
|
||||
.then((response) => response.albumList.album || [])
|
||||
.then((albumList) =>
|
||||
albumList.map((album) => ({
|
||||
id: album._id,
|
||||
name: album._name,
|
||||
year: album._year,
|
||||
genre: maybeAsGenre(album._genre),
|
||||
}))
|
||||
)
|
||||
.then(navidrome.toAlbumSummary)
|
||||
.then(slice2(q))
|
||||
.then(([page, total]) => ({
|
||||
results: page,
|
||||
@@ -555,6 +579,27 @@ export class Navidrome implements MusicService {
|
||||
})
|
||||
.then((_) => true)
|
||||
.catch(() => false),
|
||||
searchArtists: async (query: string) =>
|
||||
navidrome
|
||||
.search3(credentials, { query, artistCount: 20 })
|
||||
.then(({ artists }) =>
|
||||
artists.map((artist) => ({
|
||||
id: artist._id,
|
||||
name: artist._name,
|
||||
}))
|
||||
),
|
||||
searchAlbums: async (query: string) =>
|
||||
navidrome
|
||||
.search3(credentials, { query, albumCount: 20 })
|
||||
.then(({ albums }) => navidrome.toAlbumSummary(albums)),
|
||||
searchTracks: async (query: string) =>
|
||||
navidrome
|
||||
.search3(credentials, { query, songCount: 20 })
|
||||
.then(({ songs }) =>
|
||||
Promise.all(
|
||||
songs.map((it) => navidrome.getTrack(credentials, it._id))
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
return Promise.resolve(musicLibrary);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MusicService, isSuccess } from "./music_service";
|
||||
import bindSmapiSoapServiceToExpress from "./smapi";
|
||||
import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens";
|
||||
import logger from "./logger";
|
||||
import { Clock, SystemClock } from "./clock";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||
|
||||
@@ -24,7 +25,8 @@ function server(
|
||||
webAddress: string | "http://localhost:4534",
|
||||
musicService: MusicService,
|
||||
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
||||
accessTokens: AccessTokens = new AccessTokenPerAuthToken()
|
||||
accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
|
||||
clock: Clock = SystemClock
|
||||
): Express {
|
||||
const app = express();
|
||||
|
||||
@@ -125,6 +127,15 @@ function server(
|
||||
</imageSizeMap>
|
||||
</Match>
|
||||
</PresentationMap>
|
||||
<PresentationMap type="Search">
|
||||
<Match>
|
||||
<SearchCategories>
|
||||
<Category id="artists"/>
|
||||
<Category id="albums"/>
|
||||
<Category id="tracks"/>
|
||||
</SearchCategories>
|
||||
</Match>
|
||||
</PresentationMap>
|
||||
</Presentation>`);
|
||||
});
|
||||
|
||||
@@ -198,7 +209,8 @@ function server(
|
||||
webAddress,
|
||||
linkCodes,
|
||||
musicService,
|
||||
accessTokens
|
||||
accessTokens,
|
||||
clock
|
||||
);
|
||||
|
||||
return app;
|
||||
|
||||
93
src/smapi.ts
93
src/smapi.ts
@@ -18,6 +18,7 @@ import {
|
||||
} from "./music_service";
|
||||
import { AccessTokens } from "./access_tokens";
|
||||
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
|
||||
import { Clock } from "./clock";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
export const SOAP_PATH = "/ws/sonos";
|
||||
@@ -107,6 +108,26 @@ export function getMetadataResult(
|
||||
};
|
||||
}
|
||||
|
||||
export type SearchResponse = {
|
||||
searchResult: getMetadataResult;
|
||||
};
|
||||
|
||||
export function searchResult(
|
||||
result: Partial<getMetadataResult>
|
||||
): SearchResponse {
|
||||
const count =
|
||||
(result?.mediaCollection?.length || 0) +
|
||||
(result?.mediaMetadata?.length || 0);
|
||||
return {
|
||||
searchResult: {
|
||||
count,
|
||||
index: 0,
|
||||
total: count,
|
||||
...result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class SonosSoap {
|
||||
linkCodes: LinkCodes;
|
||||
webAddress: string;
|
||||
@@ -168,7 +189,7 @@ class SonosSoap {
|
||||
}
|
||||
|
||||
export type Container = {
|
||||
itemType: "container";
|
||||
itemType: "container" | "search";
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
@@ -185,6 +206,12 @@ const container = ({
|
||||
title,
|
||||
});
|
||||
|
||||
const search = ({ id, title }: { id: string; title: string }): Container => ({
|
||||
itemType: "search",
|
||||
id,
|
||||
title,
|
||||
});
|
||||
|
||||
const genre = (genre: Genre) => ({
|
||||
itemType: "container",
|
||||
id: `genre:${genre.id}`,
|
||||
@@ -235,10 +262,10 @@ export const track = (
|
||||
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album),
|
||||
artist: track.artist.name,
|
||||
artistId: track.artist.id,
|
||||
duration: track.duration,
|
||||
duration: `${track.duration}`,
|
||||
genre: track.album.genre?.name,
|
||||
genreId: track.album.genre?.id,
|
||||
trackNumber: track.number,
|
||||
trackNumber: `${track.number}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -300,7 +327,8 @@ function bindSmapiSoapServiceToExpress(
|
||||
webAddress: string,
|
||||
linkCodes: LinkCodes,
|
||||
musicService: MusicService,
|
||||
accessTokens: AccessTokens
|
||||
accessTokens: AccessTokens,
|
||||
clock: Clock
|
||||
) {
|
||||
const sonosSoap = new SonosSoap(webAddress, linkCodes);
|
||||
const soapyService = listen(
|
||||
@@ -312,6 +340,13 @@ function bindSmapiSoapServiceToExpress(
|
||||
getAppLink: () => sonosSoap.getAppLink(),
|
||||
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
||||
sonosSoap.getDeviceAuthToken({ linkCode }),
|
||||
getLastUpdate: () => ({
|
||||
getLastUpdateResult: {
|
||||
favorites: clock.now().unix(),
|
||||
catalog: clock.now().unix(),
|
||||
pollInterval: 120,
|
||||
},
|
||||
}),
|
||||
getMediaURI: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
@@ -339,6 +374,46 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaMetadataResult: track(webAddress, accessToken, it),
|
||||
}))
|
||||
),
|
||||
search: async (
|
||||
{ id, term }: { id: string; term: string },
|
||||
_,
|
||||
headers?: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, id, headers).then(
|
||||
async ({ musicLibrary, accessToken }) => {
|
||||
switch (id) {
|
||||
case "albums":
|
||||
return musicLibrary.searchAlbums(term).then((it) =>
|
||||
searchResult({
|
||||
count: it.length,
|
||||
mediaCollection: it.map((albumSummary) =>
|
||||
album(webAddress, accessToken, albumSummary)
|
||||
),
|
||||
})
|
||||
);
|
||||
case "artists":
|
||||
return musicLibrary.searchArtists(term).then((it) =>
|
||||
searchResult({
|
||||
count: it.length,
|
||||
mediaCollection: it.map((artistSummary) =>
|
||||
artist(webAddress, accessToken, artistSummary)
|
||||
),
|
||||
})
|
||||
);
|
||||
case "tracks":
|
||||
return musicLibrary.searchTracks(term).then((it) =>
|
||||
searchResult({
|
||||
count: it.length,
|
||||
mediaCollection: it.map((aTrack) =>
|
||||
track(webAddress, accessToken, aTrack)
|
||||
),
|
||||
})
|
||||
);
|
||||
default:
|
||||
throw `Unsupported search by:${id}`;
|
||||
}
|
||||
}
|
||||
),
|
||||
getExtendedMetadata: async (
|
||||
{
|
||||
id,
|
||||
@@ -436,6 +511,16 @@ function bindSmapiSoapServiceToExpress(
|
||||
index: 0,
|
||||
total: 8,
|
||||
});
|
||||
case "search":
|
||||
return getMetadataResult({
|
||||
mediaCollection: [
|
||||
search({ id: "artists", title: "Artists" }),
|
||||
search({ id: "albums", title: "Albums" }),
|
||||
search({ id: "tracks", title: "Tracks" }),
|
||||
],
|
||||
index: 0,
|
||||
total: 3,
|
||||
});
|
||||
case "artists":
|
||||
return musicLibrary.artists(paging).then((result) => {
|
||||
return getMetadataResult({
|
||||
|
||||
10
src/sonos.ts
10
src/sonos.ts
@@ -7,8 +7,8 @@ import logger from "./logger";
|
||||
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
|
||||
import qs from "querystring"
|
||||
|
||||
export const STRINGS_VERSION = "2";
|
||||
export const PRESENTATION_MAP_VERSION = "7";
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "12";
|
||||
|
||||
export type Capability =
|
||||
| "search"
|
||||
| "trFavorites"
|
||||
@@ -19,7 +19,7 @@ export type Capability =
|
||||
| "authorizationHeader";
|
||||
|
||||
export const BONOB_CAPABILITIES: Capability[] = [
|
||||
// "search",
|
||||
"search",
|
||||
// "trFavorites",
|
||||
// "alFavorites",
|
||||
// "ucPlaylists",
|
||||
@@ -59,11 +59,11 @@ export const bonobService = (
|
||||
secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
|
||||
strings: {
|
||||
uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`,
|
||||
version: STRINGS_VERSION,
|
||||
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||
},
|
||||
presentation: {
|
||||
uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`,
|
||||
version: PRESENTATION_MAP_VERSION,
|
||||
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||
},
|
||||
pollInterval: 1200,
|
||||
authType,
|
||||
|
||||
Reference in New Issue
Block a user