diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 769b479..f306284 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,8 +20,9 @@ "customizations": { "vscode": { "extensions": [ - "esbenp.prettier-vscode" - ] + "esbenp.prettier-vscode", + "redhat.vscode-xml" + ] } } } diff --git a/src/i8n.ts b/src/i8n.ts index 7cc9cd4..666ec61 100644 --- a/src/i8n.ts +++ b/src/i8n.ts @@ -9,6 +9,7 @@ export type KEY = | "AppLinkMessage" | "artists" | "albums" + | "internetRadio" | "playlists" | "genres" | "random" @@ -51,6 +52,7 @@ const translations: Record> = { AppLinkMessage: "Linking sonos with $BNB_SONOS_SERVICE_NAME", artists: "Artists", albums: "Albums", + internetRadio: "Internet Radio", tracks: "Tracks", playlists: "Playlists", genres: "Genres", @@ -92,6 +94,7 @@ const translations: Record> = { AppLinkMessage: "Forbinder Sonos med $BNB_SONOS_SERVICE_NAME", artists: "Kunstnere", albums: "Album", + internetRadio: "Internet Radio", tracks: "Numre", playlists: "Afspilningslister", genres: "Genre", @@ -133,6 +136,7 @@ const translations: Record> = { AppLinkMessage: "Associer Sonos à $BNB_SONOS_SERVICE_NAME", artists: "Artistes", albums: "Albums", + internetRadio: "Radio Internet", tracks: "Pistes", playlists: "Playlists", genres: "Genres", @@ -174,6 +178,7 @@ const translations: Record> = { AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME", artists: "Artiesten", albums: "Albums", + internetRadio: "Internet Radio", tracks: "Nummers", playlists: "Afspeellijsten", genres: "Genres", diff --git a/src/icon.ts b/src/icon.ts index dc9cf22..217de4d 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -163,6 +163,7 @@ export const HOLI_COLORS = [ export type ICON = | "artists" | "albums" + | "radio" | "playlists" | "genres" | "random" @@ -240,6 +241,7 @@ const iconFrom = (name: string) => export const ICONS: Record = { artists: iconFrom("navidrome-artists.svg"), albums: iconFrom("navidrome-all.svg"), + radio: iconFrom("navidrome-radio.svg"), blank: iconFrom("blank.svg"), playlists: iconFrom("navidrome-playlists.svg"), genres: iconFrom("Theatre-Mask-111172.svg"), diff --git a/src/music_service.ts b/src/music_service.ts index 0e879a0..2fa350d 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -69,6 +69,13 @@ export type Track = { rating: Rating; }; +export type RadioStation = { + id: string, + name: string, + url: string, + homePage?: string +} + export type Paging = { _index: number; _count: number; @@ -188,4 +195,6 @@ export interface MusicLibrary { removeFromPlaylist(playlistId: string, indicies: number[]): Promise similarSongs(id: string): Promise; topSongs(artistId: string): Promise; + radioStation(id: string): Promise + radioStations(): Promise } diff --git a/src/smapi.ts b/src/smapi.ts index 77fa0b1..6c0bd35 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -17,6 +17,7 @@ import { Genre, MusicService, Playlist, + RadioStation, Rating, slice2, Track, @@ -299,6 +300,13 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ // canAddToFavorites: true }); +export const internetRadioStation = (station: RadioStation) => ({ + itemType: "stream", + id: `internetRadioStation:${station.id}`, + title: station.name, + mimeType: "audio/mpeg", +}); + export const track = (bonobUrl: URLBuilder, track: Track) => ({ itemType: "track", id: `track:${track.id}`, @@ -426,9 +434,7 @@ function bindSmapiSoapServiceToExpress( }, }, })), - TE.getOrElse(() => - T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED) - ) + TE.getOrElse(() => T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED)) )(); } else { throw authOrFail.toSmapiFault(); @@ -487,27 +493,38 @@ function bindSmapiSoapServiceToExpress( ) => login(soapyHeaders?.credentials) .then(splitId(id)) - .then(({ credentials, type, typeId }) => ({ - getMediaURIResult: bonobUrl - .append({ - pathname: `/stream/${type}/${typeId}`, - }) - .href(), - httpHeaders: [ - { - httpHeader: { - header: "bnbt", - value: credentials.loginToken.token, - }, - }, - { - httpHeader: { - header: "bnbk", - value: credentials.loginToken.key, - }, - }, - ], - })), + .then(({ musicLibrary, credentials, type, typeId }) => { + switch (type) { + case "internetRadioStation": + return musicLibrary.radioStation(typeId).then((it) => ({ + getMediaURIResult: it.url, + })); + case "track": + return { + getMediaURIResult: bonobUrl + .append({ + pathname: `/stream/${type}/${typeId}`, + }) + .href(), + httpHeaders: [ + { + httpHeader: { + header: "bnbt", + value: credentials.loginToken.token, + }, + }, + { + httpHeader: { + header: "bnbk", + value: credentials.loginToken.key, + }, + }, + ], + }; + default: + throw `Unsupported type:${type}`; + } + }), getMediaMetadata: async ( { id }: { id: string }, _, @@ -515,11 +532,20 @@ function bindSmapiSoapServiceToExpress( ) => login(soapyHeaders?.credentials) .then(splitId(id)) - .then(async ({ musicLibrary, apiKey, typeId }) => - musicLibrary.track(typeId!).then((it) => ({ - getMediaMetadataResult: track(urlWithToken(apiKey), it), - })) - ), + .then(async ({ musicLibrary, apiKey, type, typeId }) => { + switch (type) { + case "internetRadioStation": + return musicLibrary.radioStation(typeId).then((it) => ({ + getMediaMetadataResult: internetRadioStation(it), + })); + case "track": + return musicLibrary.track(typeId!).then((it) => ({ + getMediaMetadataResult: track(urlWithToken(apiKey), it), + })); + default: + throw `Unsupported type:${type}`; + } + }), search: async ( { id, term }: { id: string; term: string }, _, @@ -741,6 +767,12 @@ function bindSmapiSoapServiceToExpress( ).href(), itemType: "albumList", }, + { + id: "internetRadio", + title: lang("internetRadio"), + albumArtURI: iconArtURI(bonobUrl, "radio").href(), + itemType: "stream", + }, ], }); case "search": @@ -815,6 +847,19 @@ function bindSmapiSoapServiceToExpress( type: "mostPlayed", ...paging, }); + case "internetRadio": + return musicLibrary + .radioStations() + .then(slice2(paging)) + .then(([page, total]) => + getMetadataResult({ + mediaMetadata: page.map((it) => + internetRadioStation(it) + ), + index: paging._index, + total, + }) + ); case "genres": return musicLibrary .genres() @@ -840,10 +885,9 @@ function bindSmapiSoapServiceToExpress( name: playlist.name, coverArt: playlist.coverArt, // todo: are these every important? - entries: [] + entries: [], }; - } - ) + }) ) ) .then(slice2(paging)) @@ -875,15 +919,15 @@ function bindSmapiSoapServiceToExpress( .artist(typeId!) .then((artist) => artist.albums) .then(slice2(paging)) - .then(([page, total]) => { - return getMetadataResult({ + .then(([page, total]) => + getMetadataResult({ mediaCollection: page.map((it) => album(urlWithToken(apiKey), it) ), index: paging._index, total, - }); - }); + }) + ); case "relatedArtists": return musicLibrary .artist(typeId!) diff --git a/src/subsonic.ts b/src/subsonic.ts index af46e83..c0fef83 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -205,6 +205,15 @@ type GetTopSongsResponse = { topSongs: { song: song[] }; }; +type GetInternetRadioStationsResponse = { + internetRadioStations: { internetRadioStation: { + id: string, + name: string, + streamUrl: string, + homePageUrl?: string }[] + } +} + type GetSongResponse = { song: song; }; @@ -1011,6 +1020,24 @@ export class Subsonic implements MusicService { ) ) ), + radioStations: async () => subsonic + .getJSON( + credentials, + "/rest/getInternetRadioStations" + ) + .then((it) => it.internetRadioStations.internetRadioStation || []) + .then((stations) => stations.map((it) => ({ + id: it.id, + name: it.name, + url: it.streamUrl, + homePage: it.homePageUrl + }))), + radioStation: async (id: string) => genericSubsonic + .radioStations() + .then(it => + it.find(station => station.id === id)! + ), + }; if (credentials.type == "navidrome") { diff --git a/tests/builders.ts b/tests/builders.ts index 5dadc61..6dc65fd 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -14,6 +14,7 @@ import { Playlist, SimilarArtist, AlbumSummary, + RadioStation, } from "../src/music_service"; import { b64Encode } from "../src/b64"; @@ -204,6 +205,17 @@ export function anAlbum(fields: Partial = {}): Album { }; }; +export function aRadioStation(fields: Partial = {}): RadioStation { + const id = uuid() + const name = `Station-${id}`; + return { + id, + name, + url: `http://example.com/${name}`, + ...fields + } +} + export function anAlbumSummary(fields: Partial = {}): AlbumSummary { const id = uuid(); return { diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 6d3a30a..644586a 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -161,6 +161,8 @@ export class InMemoryMusicService implements MusicService { Promise.reject("Unsupported operation"), similarSongs: async (_: string) => Promise.resolve([]), topSongs: async (_: string) => Promise.resolve([]), + radioStations: async () => Promise.resolve([]), + radioStation: async (_: string) => Promise.reject("Unsupported operation"), }); } diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index bdc1e48..17f5654 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -24,6 +24,7 @@ import { sonosifyMimeType, ratingAsInt, ratingFromInt, + internetRadioStation } from "../src/smapi"; import { keys as i8nKeys } from "../src/i8n"; @@ -39,6 +40,7 @@ import { TRIP_HOP, PUNK, aPlaylist, + aRadioStation, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; @@ -481,6 +483,18 @@ describe("album", () => { }); }); +describe("internetRadioStation", () => { + it("should map to a sonos internet stream", () => { + const station = aRadioStation() + expect(internetRadioStation(station)).toEqual({ + itemType: "stream", + id: `internetRadioStation:${station.id}`, + title: station.name, + mimeType: "audio/mpeg" + }) + }); +}); + describe("sonosifyMimeType", () => { describe("when is audio/x-flac", () => { it("should be mapped to audio/flac", () => { @@ -577,6 +591,8 @@ describe("wsdl api", () => { scrobble: jest.fn(), nowPlaying: jest.fn(), rate: jest.fn(), + radioStation: jest.fn(), + radioStations: jest.fn(), }; const apiTokens = { mint: jest.fn(), @@ -1158,6 +1174,12 @@ describe("wsdl api", () => { albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(), itemType: "albumList", }, + { + id: "internetRadio", + title: "Internet Radio", + albumArtURI: iconArtURI(bonobUrl, "radio").href(), + itemType: "stream", + }, ]; expect(root[0]).toEqual( getMetadataResult({ @@ -1246,6 +1268,12 @@ describe("wsdl api", () => { albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(), itemType: "albumList", }, + { + id: "internetRadio", + title: "Internet Radio", + albumArtURI: iconArtURI(bonobUrl, "radio").href(), + itemType: "stream", + }, ]; expect(root[0]).toEqual( getMetadataResult({ @@ -2375,6 +2403,71 @@ describe("wsdl api", () => { }); }); }); + + describe("asking for internet radio stations", () => { + const station1 = aRadioStation(); + const station2 = aRadioStation(); + const station3 = aRadioStation(); + const station4 = aRadioStation(); + + const stations = [station1, station2, station3, station4]; + + beforeEach(() => { + musicLibrary.radioStations.mockResolvedValue(stations); + }); + + describe("when they all fit on the page", () => { + it("should return them all", async () => { + const paging = { + index: 0, + count: 100, + }; + + const result = await ws.getMetadataAsync({ + id: `internetRadio`, + ...paging, + }); + + expect(result[0]).toEqual( + getMetadataResult({ + mediaMetadata: stations.map((it) => + internetRadioStation(it) + ), + index: 0, + total: stations.length, + }) + ); + expect(musicLibrary.radioStations).toHaveBeenCalled(); + }); + }); + + describe("asking for a single page of stations", () => { + const pageOfStations = [station3, station4]; + + it("should return only that page", async () => { + const paging = { + index: 2, + count: 2, + }; + + const result = await ws.getMetadataAsync({ + id: `internetRadio`, + ...paging, + }); + + expect(result[0]).toEqual( + getMetadataResult({ + mediaMetadata: pageOfStations.map((it) => + internetRadioStation(it) + ), + index: paging.index, + total: stations.length, + }) + ); + expect(musicLibrary.radioStations).toHaveBeenCalled(); + }); + }); + }); }); }); @@ -2752,6 +2845,27 @@ describe("wsdl api", () => { expect(musicService.login).toHaveBeenCalledWith(serviceToken); }); }); + + describe("asking for a URI to stream a radio station", () => { + const someStation = aRadioStation() + + beforeEach(() => { + musicLibrary.radioStation.mockResolvedValue(someStation); + }) + + it("should return the radio stations uri", async () => { + const root = await ws.getMediaURIAsync({ + id: `internetRadioStation:${someStation.id}`, + }); + + expect(root[0]).toEqual({ + getMediaURIResult: someStation.url, + }); + + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.radioStation).toHaveBeenCalledWith(someStation.id); + }); + }); }); }); @@ -2763,7 +2877,6 @@ describe("wsdl api", () => { describe("when valid credentials are provided", () => { let ws: Client; - const someTrack = aTrack(); beforeEach(async () => { ws = await createClientAsync(`${service.uri}?wsdl`, { @@ -2771,10 +2884,15 @@ describe("wsdl api", () => { httpClient: supersoap(server), }); setupAuthenticatedRequest(ws); - musicLibrary.track.mockResolvedValue(someTrack); }); describe("asking for media metadata for a track", () => { + const someTrack = aTrack(); + + beforeEach(async () => { + musicLibrary.track.mockResolvedValue(someTrack); + }); + it("should return it with auth header", async () => { const root = await ws.getMediaMetadataAsync({ id: `track:${someTrack.id}`, @@ -2793,6 +2911,27 @@ describe("wsdl api", () => { expect(musicLibrary.track).toHaveBeenCalledWith(someTrack.id); }); }); + + describe("asking for media metadata for an internet radio station", () => { + const someStation = aRadioStation() + + beforeEach(() => { + musicLibrary.radioStation.mockResolvedValue(someStation); + }) + + it("should return it with no auth header", async () => { + const root = await ws.getMediaMetadataAsync({ + id: `internetRadioStation:${someStation.id}`, + }); + + expect(root[0]).toEqual({ + getMediaMetadataResult: internetRadioStation(someStation), + }); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.radioStation).toHaveBeenCalledWith(someStation.id); + }); + }); }); }); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 06e0c45..25d32b9 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -49,7 +49,8 @@ import { SimilarArtist, Rating, Credentials, - AuthFailure + AuthFailure, + RadioStation } from "../src/music_service"; import { aGenre, @@ -61,6 +62,7 @@ import { aTrack, POP, ROCK, + aRadioStation } from "./builders"; import { b64Encode } from "../src/b64"; import { BUrn } from "../src/burn"; @@ -348,6 +350,18 @@ const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: artist: asArtistJson(artist, extras), }); +const getRadioStationsJson = (radioStations: RadioStation[]) => + subsonicOK({ + internetRadioStations: { + internetRadioStation: radioStations.map((it) => ({ + id: it.id, + name: it.name, + streamUrl: it.url, + homePageUrl: it.homePage + })) + }, + }); + const asGenreJson = (genre: { name: string; albumCount: number }) => ({ songCount: 1475, albumCount: genre.albumCount, @@ -5028,4 +5042,86 @@ describe("Subsonic", () => { }); }); }); + + describe("radioStations", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when there some radio stations", () => { + const station1 = aRadioStation(); + const station2 = aRadioStation(); + const station3 = aRadioStation(); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getRadioStationsJson([ + station1, + station2, + station3, + ]))) + ); + }); + + describe("asking for all of them", () => { + it("should return them all", async () => { + const result = await login({ username, password }) + .then((it) => it.radioStations()); + + expect(result).toEqual([station1, station2, station3]); + + expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { + params: asURLSearchParams({ + ...authParams, + f: "json" + }), + headers, + }); + }); + }); + + describe("asking for one of them", () => { + it("should return it", async () => { + const result = await login({ username, password }) + .then((it) => it.radioStation(station2.id)); + + expect(result).toEqual(station2); + + expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { + params: asURLSearchParams({ + ...authParams, + f: "json" + }), + headers, + }); + }); + }); + }); + + describe("when there are no radio stations", () => { + it("should return []", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getRadioStationsJson([]))) + ); + + const result = await login({ username, password }) + .then((it) => it.radioStations()); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { + params: asURLSearchParams({ + ...authParams, + f: "json" + }), + headers, + }); + }); + }); + }); + }); diff --git a/web/icons/navidrome-radio.svg b/web/icons/navidrome-radio.svg new file mode 100644 index 0000000..3e519ed --- /dev/null +++ b/web/icons/navidrome-radio.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file