import crypto from "crypto"; import { Express, Request } from "express"; import { listen } from "soap"; import { readFileSync } from "fs"; import path from "path"; import logger from "./logger"; import { LinkCodes } from "./link_codes"; import { Album, AlbumQuery, AlbumSummary, ArtistSummary, Genre, MusicService, Playlist, Rating, slice2, Track, } from "./music_service"; import { AccessTokens } from "./access_tokens"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; import { asLANGs, I8N } from "./i8n"; import { ICON, iconForGenre } from "./icon"; import { uniq } from "underscore"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; export const REMOVE_REGISTRATION_ROUTE = "/registration/remove"; export const SOAP_PATH = "/ws/sonos"; export const STRINGS_ROUTE = "/sonos/strings.xml"; export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml"; export const SONOS_RECOMMENDED_IMAGE_SIZES = [ "60", "80", "120", "180", "192", "200", "230", "300", "600", "640", "750", "1242", "1500", ]; const WSDL_FILE = path.resolve( __dirname, "Sonoswsdl-1.19.4-20190411.142401-3.wsdl" ); export type Credentials = { loginToken: { token: string; householdId: string; }; deviceId: string; deviceProvider: string; }; export type GetAppLinkResult = { getAppLinkResult: { authorizeAccount: { appUrlStringId: string; deviceLink: { regUrl: string; linkCode: string; showLinkCode: boolean }; }; }; }; export type GetDeviceAuthTokenResult = { getDeviceAuthTokenResult: { authToken: string; privateKey: string; userInfo: { nickname: string; userIdHashCode: string; }; }; }; export const ratingAsInt = (rating: Rating): number => rating.stars * 10 + (rating.love ? 1 : 0) + 100; export const ratingFromInt = (value: number): Rating => { const x = value - 100; return { love: (x % 10 == 1), stars: Math.floor(x / 10) } }; export type MediaCollection = { id: string; itemType: "collection"; title: string; }; export type getMetadataResult = { count: number; index: number; total: number; mediaCollection?: any[]; mediaMetadata?: any[]; }; export type GetMetadataResponse = { getMetadataResult: getMetadataResult; }; export function getMetadataResult( result: Partial ): GetMetadataResponse { const count = (result?.mediaCollection?.length || 0) + (result?.mediaMetadata?.length || 0); return { getMetadataResult: { count, index: 0, total: count, ...result, }, }; } export type SearchResponse = { searchResult: getMetadataResult; }; export function searchResult( result: Partial ): SearchResponse { const count = (result?.mediaCollection?.length || 0) + (result?.mediaMetadata?.length || 0); return { searchResult: { count, index: 0, total: count, ...result, }, }; } class SonosSoap { linkCodes: LinkCodes; bonobUrl: URLBuilder; constructor(bonobUrl: URLBuilder, linkCodes: LinkCodes) { this.bonobUrl = bonobUrl; this.linkCodes = linkCodes; } getAppLink(): GetAppLinkResult { const linkCode = this.linkCodes.mint(); return { getAppLinkResult: { authorizeAccount: { appUrlStringId: "AppLinkMessage", deviceLink: { regUrl: this.bonobUrl .append({ pathname: LOGIN_ROUTE }) .with({ searchParams: { linkCode } }) .href(), linkCode: linkCode, showLinkCode: false, }, }, }, }; } getDeviceAuthToken({ linkCode, }: { linkCode: string; }): GetDeviceAuthTokenResult { const association = this.linkCodes.associationFor(linkCode); if (association) { return { getDeviceAuthTokenResult: { authToken: association.authToken, privateKey: "", userInfo: { nickname: association.nickname, userIdHashCode: crypto .createHash("sha256") .update(association.userId) .digest("hex"), }, }, }; } else { logger.info( "Client not linked, awaiting user to associate account with link code by logging in." ); throw { Fault: { faultcode: "Client.NOT_LINKED_RETRY", faultstring: "Link Code not found yet, sonos app will keep polling until you log in to bonob", detail: { ExceptionInfo: "NOT_LINKED_RETRY", SonosError: "5", }, }, }; } } } export type ContainerType = "container" | "search" | "albumList"; export type Container = { itemType: ContainerType; id: string; title: string; displayType: string | undefined; }; const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ itemType: "container", id: `genre:${genre.id}`, title: genre.name, albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(), }); const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, albumArtURI: playlistAlbumArtURL(bonobUrl, playlist).href(), canPlay: true, attributes: { readOnly: false, userContent: false, renameable: false, }, }); export const playlistAlbumArtURL = ( bonobUrl: URLBuilder, playlist: Playlist ) => { const ids = uniq( playlist.entries.map((it) => it.coverArt).filter((it) => it) ); if (ids.length == 0) { return iconArtURI(bonobUrl, "error"); } else { return bonobUrl.append({ pathname: `/art/${ids.slice(0, 9).join("&")}/size/180`, }); } }; export const defaultAlbumArtURI = ( bonobUrl: URLBuilder, { coverArt }: { coverArt: string | undefined } ) => coverArt ? bonobUrl.append({ pathname: `/art/${coverArt}/size/180` }) : iconArtURI(bonobUrl, "vinyl"); export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => bonobUrl.append({ pathname: `/icon/${icon}/size/legacy`, }); export const defaultArtistArtURI = ( bonobUrl: URLBuilder, artist: ArtistSummary ) => bonobUrl.append({ pathname: `/art/artist:${artist.id}/size/180` }); export const sonosifyMimeType = (mimeType: string) => mimeType == "audio/x-flac" ? "audio/flac" : mimeType; export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ itemType: "album", id: `album:${album.id}`, artist: album.artistName, artistId: `artist:${album.artistId}`, title: album.name, albumArtURI: defaultAlbumArtURI(bonobUrl, album).href(), canPlay: true, // defaults // canScroll: false, // canEnumerate: true, // canAddToFavorites: true }); export const track = (bonobUrl: URLBuilder, track: Track) => ({ itemType: "track", id: `track:${track.id}`, mimeType: sonosifyMimeType(track.mimeType), title: track.name, trackMetadata: { album: track.album.name, albumId: `album:${track.album.id}`, albumArtist: track.artist.name, albumArtistId: `artist:${track.artist.id}`, albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(), artist: track.artist.name, artistId: `artist:${track.artist.id}`, duration: track.duration, genre: track.album.genre?.name, genreId: track.album.genre?.id, trackNumber: track.number, }, dynamic: { property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }], } }); export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ itemType: "artist", id: `artist:${artist.id}`, artistId: artist.id, title: artist.name, albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(), }); const auth = async ( musicService: MusicService, accessTokens: AccessTokens, credentials?: Credentials ) => { if (!credentials) { throw { Fault: { faultcode: "Client.LoginUnsupported", faultstring: "Missing credentials...", }, }; } const authToken = credentials.loginToken.token; const accessToken = accessTokens.mint(authToken); return musicService .login(authToken) .then((musicLibrary) => ({ musicLibrary, authToken, accessToken, })) .catch((_) => { throw { Fault: { faultcode: "Client.LoginUnauthorized", faultstring: "Credentials not found...", }, }; }); }; function splitId(id: string) { const [type, typeId] = id.split(":"); return (t: T) => ({ ...t, type, typeId: typeId!, }); } type SoapyHeaders = { credentials?: Credentials; }; function bindSmapiSoapServiceToExpress( app: Express, soapPath: string, bonobUrl: URLBuilder, linkCodes: LinkCodes, musicService: MusicService, accessTokens: AccessTokens, clock: Clock, i8n: I8N ) { const sonosSoap = new SonosSoap(bonobUrl, linkCodes); const urlWithToken = (accessToken: string) => bonobUrl.append({ searchParams: { bat: accessToken, }, }); const soapyService = listen( app, soapPath, { Sonos: { SonosSoap: { getAppLink: () => sonosSoap.getAppLink(), getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => sonosSoap.getDeviceAuthToken({ linkCode }), getLastUpdate: () => ({ getLastUpdateResult: { autoRefreshEnabled: true, favorites: clock.now().unix(), catalog: clock.now().unix(), pollInterval: 60, }, }), getMediaURI: async ( { id }: { id: string }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ accessToken, type, typeId }) => ({ getMediaURIResult: bonobUrl .append({ pathname: `/stream/${type}/${typeId}`, searchParams: { bat: accessToken }, }) .href(), })), getMediaMetadata: async ( { id }: { id: string }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(async ({ musicLibrary, accessToken, typeId }) => musicLibrary.track(typeId!).then((it) => ({ getMediaMetadataResult: track( urlWithToken(accessToken), it ), })) ), search: async ( { id, term }: { id: string; term: string }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(async ({ musicLibrary, accessToken }) => { switch (id) { case "albums": return musicLibrary.searchAlbums(term).then((it) => searchResult({ count: it.length, mediaCollection: it.map((albumSummary) => album(urlWithToken(accessToken), albumSummary) ), }) ); case "artists": return musicLibrary.searchArtists(term).then((it) => searchResult({ count: it.length, mediaCollection: it.map((artistSummary) => artist(urlWithToken(accessToken), artistSummary) ), }) ); case "tracks": return musicLibrary.searchTracks(term).then((it) => searchResult({ count: it.length, mediaCollection: it.map((aTrack) => album(urlWithToken(accessToken), aTrack.album) ), }) ); default: throw `Unsupported search by:${id}`; } }), getExtendedMetadata: async ( { id, index, count, }: // recursive, { id: string; index: number; count: number; recursive: boolean }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(async ({ musicLibrary, accessToken, type, typeId }) => { const paging = { _index: index, _count: count }; switch (type) { case "artist": return musicLibrary.artist(typeId).then((artist) => { const [page, total] = slice2(paging)( artist.albums ); return { getExtendedMetadataResult: { count: page.length, index: paging._index, total, mediaCollection: page.map((it) => album(urlWithToken(accessToken), it) ), relatedBrowse: artist.similarArtists.filter((it) => it.inLibrary) .length > 0 ? [ { id: `relatedArtists:${artist.id}`, type: "RELATED_ARTISTS", }, ] : [], }, }; }); case "track": return musicLibrary.track(typeId).then((it) => ({ getExtendedMetadataResult: { mediaMetadata: track( urlWithToken(accessToken), it ), }, })); case "album": return musicLibrary.album(typeId).then((it) => ({ getExtendedMetadataResult: { mediaCollection: { attributes: { readOnly: true, userContent: false, renameable: false, }, ...album(urlWithToken(accessToken), it), }, // // // // AL:123456 // ALBUM_NOTES // // }, })); default: throw `Unsupported getExtendedMetadata id=${id}`; } }), getMetadata: async ( { id, index, count, }: // recursive, { id: string; index: number; count: number; recursive: boolean }, _, soapyHeaders: SoapyHeaders, { headers }: Pick ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, accessToken, type, typeId }) => { const paging = { _index: index, _count: count }; const acceptLanguage = headers["accept-language"]; logger.debug( `Fetching metadata type=${type}, typeId=${typeId}, acceptLanguage=${acceptLanguage}` ); const lang = i8n(...asLANGs(acceptLanguage)); const albums = (q: AlbumQuery): Promise => musicLibrary.albums(q).then((result) => { return getMetadataResult({ mediaCollection: result.results.map((it) => album(urlWithToken(accessToken), it) ), index: paging._index, total: result.total, }); }); switch (type) { case "root": return getMetadataResult({ mediaCollection: [ { id: "artists", title: lang("artists"), albumArtURI: iconArtURI(bonobUrl, "artists").href(), itemType: "container", }, { id: "albums", title: lang("albums"), albumArtURI: iconArtURI(bonobUrl, "albums").href(), itemType: "albumList", }, { id: "randomAlbums", title: lang("random"), albumArtURI: iconArtURI(bonobUrl, "random").href(), itemType: "albumList", }, { id: "favouriteAlbums", title: lang("favourites"), albumArtURI: iconArtURI(bonobUrl, "heart").href(), itemType: "albumList", }, // { // id: "topRatedAlbums", // title: lang("topRated"), // albumArtURI: iconArtURI(bonobUrl, "star").href(), // itemType: "albumList", // }, { id: "playlists", title: lang("playlists"), albumArtURI: iconArtURI(bonobUrl, "playlists").href(), itemType: "playlist", attributes: { readOnly: false, userContent: true, renameable: false, }, }, { id: "genres", title: lang("genres"), albumArtURI: iconArtURI(bonobUrl, "genres").href(), itemType: "container", }, { id: "recentlyAdded", title: lang("recentlyAdded"), albumArtURI: iconArtURI( bonobUrl, "recentlyAdded" ).href(), itemType: "albumList", }, { id: "recentlyPlayed", title: lang("recentlyPlayed"), albumArtURI: iconArtURI( bonobUrl, "recentlyPlayed" ).href(), itemType: "albumList", }, { id: "mostPlayed", title: lang("mostPlayed"), albumArtURI: iconArtURI( bonobUrl, "mostPlayed" ).href(), itemType: "albumList", }, ], index: 0, total: 9, }); case "search": return getMetadataResult({ mediaCollection: [ { itemType: "search", id: "artists", title: lang("artists"), }, { itemType: "search", id: "albums", title: lang("albums"), }, { itemType: "search", id: "tracks", title: lang("tracks"), }, ], index: 0, total: 3, }); case "artists": return musicLibrary.artists(paging).then((result) => { return getMetadataResult({ mediaCollection: result.results.map((it) => artist(urlWithToken(accessToken), it) ), index: paging._index, total: result.total, }); }); case "albums": { return albums({ type: "alphabeticalByName", ...paging, }); } case "genre": return albums({ type: "byGenre", genre: typeId, ...paging, }); case "randomAlbums": return albums({ type: "random", ...paging, }); case "favouriteAlbums": return albums({ type: "starred", ...paging, }); case "recentlyAdded": return albums({ type: "newest", ...paging, }); case "recentlyPlayed": return albums({ type: "recent", ...paging, }); case "mostPlayed": return albums({ type: "frequent", ...paging, }); case "genres": return musicLibrary .genres() .then(slice2(paging)) .then(([page, total]) => getMetadataResult({ mediaCollection: page.map((it) => genre(bonobUrl, it) ), index: paging._index, total, }) ); case "playlists": return musicLibrary .playlists() .then((it) => Promise.all( it.map((playlist) => musicLibrary.playlist(playlist.id) ) ) ) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => playlist(urlWithToken(accessToken), it) ), index: paging._index, total, }); }); case "playlist": return musicLibrary .playlist(typeId!) .then((playlist) => playlist.entries) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ mediaMetadata: page.map((it) => track(urlWithToken(accessToken), it) ), index: paging._index, total, }); }); case "artist": return musicLibrary .artist(typeId!) .then((artist) => artist.albums) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => album(urlWithToken(accessToken), it) ), index: paging._index, total, }); }); case "relatedArtists": return musicLibrary .artist(typeId!) .then((artist) => artist.similarArtists) .then((similarArtists) => similarArtists.filter((it) => it.inLibrary) ) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => artist(urlWithToken(accessToken), it) ), index: paging._index, total, }); }); case "album": return musicLibrary .tracks(typeId!) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ mediaMetadata: page.map((it) => track(urlWithToken(accessToken), it) ), index: paging._index, total, }); }); default: throw `Unsupported getMetadata id=${id}`; } }), createContainer: async ( { title, seedId }: { title: string; seedId: string | undefined }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(({ musicLibrary }) => musicLibrary .createPlaylist(title) .then((playlist) => ({ playlist, musicLibrary })) ) .then(({ musicLibrary, playlist }) => { if (seedId) { musicLibrary.addToPlaylist( playlist.id, seedId.split(":")[1]! ); } return playlist; }) .then((it) => ({ createContainerResult: { id: `playlist:${it.id}`, updateId: "", }, })), deleteContainer: async ( { id }: { id: string }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) .then((_) => ({ deleteContainerResult: {} })), addToContainer: async ( { id, parentId }: { id: string; parentId: string }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) ) .then((_) => ({ addToContainerResult: { updateId: "" } })), removeFromContainer: async ( { id, indices }: { id: string; indices: string }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then((it) => ({ ...it, indices: indices.split(",").map((it) => +it), })) .then(({ musicLibrary, typeId, indices }) => { if (id == "playlists") { musicLibrary.playlists().then((it) => { indices.forEach((i) => { musicLibrary.deletePlaylist(it[i]?.id!); }); }); } else { musicLibrary.removeFromPlaylist(typeId, indices); } }) .then((_) => ({ removeFromContainerResult: { updateId: "" } })), rateItem: async ( { id, rating }: { id: string; rating: number }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating))) ) .then((_) => ({ rateItemResult: { shouldSkip: false } })), setPlayedSeconds: async ( { id, seconds }: { id: string; seconds: string }, _, soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, type, typeId }) => { switch (type) { case "track": return musicLibrary .track(typeId) .then(({ duration }) => { if ( (duration < 30 && +seconds >= 10) || (duration >= 30 && +seconds >= 30) ) { return musicLibrary.scrobble(typeId); } else { return Promise.resolve(true); } }) .then(() => { if (+seconds > 0) { return musicLibrary.nowPlaying(typeId); } else { return Promise.resolve(true); } }); break; default: logger.info("Unsupported scrobble", { id, seconds }); return Promise.resolve(true); } }) .then((_) => ({ setPlayedSecondsResult: {}, })), }, }, }, readFileSync(WSDL_FILE, "utf8"), (err: any, res: any) => { if (err) { logger.error("BOOOOM", { err, res }); } } ); soapyService.log = (type, data) => { switch (type) { case "info": logger.info({ level: "info", data }); break; case "warn": logger.warn({ level: "warn", data }); break; case "error": logger.error({ level: "error", data }); break; default: logger.debug({ level: "debug", data }); } }; } export default bindSmapiSoapServiceToExpress;