import crypto from "crypto"; import { Express } 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, PlaylistSummary, slice2, Track, } from "./music_service"; import { AccessTokens } from "./access_tokens"; import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; export const LOGIN_ROUTE = "/login"; export const REGISTER_ROUTE = "/register"; 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 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 { throw { Fault: { faultcode: "Client.NOT_LINKED_RETRY", faultstring: "Link Code not found retry...", 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 = (genre: Genre) => ({ itemType: "container", id: `genre:${genre.id}`, title: genre.name, }); const playlist = (playlist: PlaylistSummary) => ({ itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, canPlay: true, attributes: { readOnly: false, userContent: false, renameable: false, }, }); export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) => bonobUrl.append({ pathname: `/album/${album.id}/art/size/180` }); export const defaultArtistArtURI = ( bonobUrl: URLBuilder, artist: ArtistSummary ) => bonobUrl.append({ pathname: `/artist/${artist.id}/art/size/180` }); export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ itemType: "album", id: `album:${album.id}`, artist: album.artistName, artistId: 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: track.mimeType, title: track.name, trackMetadata: { album: track.album.name, albumId: track.album.id, albumArtist: track.artist.name, albumArtistId: track.artist.id, albumArtURI: defaultAlbumArtURI(bonobUrl, track.album).href(), artist: track.artist.name, artistId: track.artist.id, duration: track.duration, genre: track.album.genre?.name, genreId: track.album.genre?.id, trackNumber: track.number, }, }); 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, headers?: SoapyHeaders ) => { if (!headers?.credentials) { throw { Fault: { faultcode: "Client.LoginUnsupported", faultstring: "Missing credentials...", }, }; } const authToken = headers.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 ) { const sonosSoap = new SonosSoap(bonobUrl, linkCodes); const urlWithToken = (accessToken: string) => bonobUrl.append({ searchParams: { "bonob-access-token": 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 }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .then(splitId(id)) .then(({ accessToken, type, typeId }) => ({ getMediaURIResult: bonobUrl .append({ pathname: `/stream/${type}/${typeId}`, }) .href(), httpHeaders: [ { header: BONOB_ACCESS_TOKEN_HEADER, value: accessToken, }, ], })), getMediaMetadata: async ( { id }: { id: string }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .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 }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .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 }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .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.length > 0 ? [ { id: `relatedArtists:${artist.id}`, type: "RELATED_ARTISTS", }, ] : [], }, }; }); case "track": return musicLibrary.track(typeId).then((it) => ({ getExtendedMetadataResult: { mediaMetadata: { id: `track:${it.id}`, itemType: "track", title: it.name, mimeType: it.mimeType, trackMetadata: { artistId: `artist:${it.artist.id}`, artist: it.artist.name, albumId: `album:${it.album.id}`, album: it.album.name, genre: it.genre?.name, genreId: it.genre?.id, duration: it.duration, albumArtURI: defaultAlbumArtURI( urlWithToken(accessToken), it.album ).href(), }, }, }, })); 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 }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .then(splitId(id)) .then(({ musicLibrary, accessToken, type, typeId }) => { const paging = { _index: index, _count: count }; logger.debug( `Fetching metadata type=${type}, typeId=${typeId}` ); 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: [ { itemType: "container", id: "artists", title: "Artists", }, { itemType: "albumList", id: "albums", title: "Albums", }, { itemType: "playlist", id: "playlists", title: "Playlists", attributes: { readOnly: false, userContent: true, renameable: false, }, }, { itemType: "container", id: "genres", title: "Genres", }, { itemType: "albumList", id: "randomAlbums", title: "Random", }, { itemType: "albumList", id: "starredAlbums", title: "Starred", }, { itemType: "albumList", id: "recentlyAdded", title: "Recently Added", }, { itemType: "albumList", id: "recentlyPlayed", title: "Recently Played", }, { itemType: "albumList", id: "mostPlayed", title: "Most Played", }, ], index: 0, total: 9, }); case "search": return getMetadataResult({ mediaCollection: [ { itemType: "search", id: "artists", title: "Artists" }, { itemType: "search", id: "albums", title: "Albums" }, { itemType: "search", id: "tracks", title: "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: "alphabeticalByArtist", ...paging, }); } case "genre": return albums({ type: "byGenre", genre: typeId, ...paging, }); case "randomAlbums": return albums({ type: "random", ...paging, }); case "starredAlbums": 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(genre), index: paging._index, total, }) ); case "playlists": return musicLibrary .playlists() .then(slice2(paging)) .then(([page, total]) => getMetadataResult({ mediaCollection: page.map(playlist), 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(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 }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .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 }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) .then((_) => ({ deleteContainerResult: {} })), addToContainer: async ( { id, parentId }: { id: string; parentId: string }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .then(splitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) ) .then((_) => ({ addToContainerResult: { updateId: "" } })), removeFromContainer: async ( { id, indices }: { id: string; indices: string }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .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: "" } })), setPlayedSeconds: async ( { id, seconds }: { id: string; seconds: string }, _, headers?: SoapyHeaders ) => auth(musicService, accessTokens, headers) .then(splitId(id)) .then(({ musicLibrary, type, typeId }) => { switch (type) { case "track": musicLibrary.track(typeId).then(({ duration }) => { if ( (duration < 30 && +seconds >= 10) || (duration >= 30 && +seconds >= 30) ) { musicLibrary.scrobble(typeId); } }); break; default: logger.info("Unsupported scrobble", { id, seconds }); break; } }) .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;