Ability to search by artist, album, track

This commit is contained in:
simojenki
2021-04-20 13:21:58 +10:00
parent 759592767f
commit d3d83df03c
12 changed files with 903 additions and 130 deletions

View File

@@ -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
View File

@@ -0,0 +1,7 @@
import dayjs, { Dayjs } from "dayjs";
export interface Clock {
now(): Dayjs;
}
export const SystemClock = { now: () => dayjs() };

View File

@@ -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[]>;
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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({

View File

@@ -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,