From 69547b18bf09e359307c20397e7dd57ae93307cb Mon Sep 17 00:00:00 2001 From: Janne Liuhtonen Date: Fri, 13 Sep 2024 22:16:09 +0300 Subject: [PATCH 1/3] Fix resolving radio tracks In some internet radio streams like Radio Paradise, the total length of the track is not available. Also, sometimes the track name is in title2 field instead of title1 field if the stream name is in title1. --- src/bluOs/player.ts | 54 +++++++++++++++++++++++++++++---- src/submitTrack.ts | 2 +- test/scrobbler.test.ts | 68 ++++++++++++++++++++++++++++++++++++++--- test/util/bluOsUtil.ts | 69 ++++++++++++++++++++++++++++++++++++------ 4 files changed, 171 insertions(+), 22 deletions(-) diff --git a/src/bluOs/player.ts b/src/bluOs/player.ts index 7c9d186..5c383fd 100644 --- a/src/bluOs/player.ts +++ b/src/bluOs/player.ts @@ -36,18 +36,24 @@ const xmlJsStatus = zod status: zod.object({ artist: xmlTextField, album: xmlTextField, + name: xmlTextField.optional(), title1: xmlTextField, + title2: xmlTextField, + title3: xmlTextField, secs: xmlTextField, - totlen: xmlTextField, + totlen: xmlTextField.optional(), state: xmlTextField, }), }) .transform((value) => ({ artist: value.status.artist._text, album: value.status.album._text, - title: value.status.title1._text, + name: value.status.name?._text, + title1: value.status.title1._text, + title2: value.status.title2._text, + title3: value.status.title3._text, secs: Number(value.status.secs._text), - totalLength: Number(value.status.totlen._text), + totalLength: value.status.totlen && Number(value.status.totlen._text), state: value.status.state._text, })) @@ -62,11 +68,21 @@ export interface StatusQueryResponse { playingTrack?: PlayingTrack } -export type PlayingTrack = zod.infer +export type PlayingBluOsTrack = zod.infer + +export type PlayingTrack = { + artist: string + album: string + title: string + secs: number + totalLength: number | undefined + state: string +} const longPollTimeoutSecs = 100 const httpRequestTimeoutMillis = longPollTimeoutSecs * 1000 + 2 const trackPlayingStates = ["play", "stream"] +const fallbackTrackLength = 90 export const isTrackPlaying = (t: PlayingTrack) => trackPlayingStates.includes(t.state) @@ -76,7 +92,7 @@ export const isSameTrack = (a: PlayingTrack, b: PlayingTrack): boolean => export const hasPlayedOverThreshold = (t: PlayingTrack, threshold: number) => t.secs / longPollTimeoutSecs >= - (t.totalLength / longPollTimeoutSecs) * threshold + ((t.totalLength ?? fallbackTrackLength) / longPollTimeoutSecs) * threshold const fetchBluOsStatus = ( logger: Logger, @@ -99,6 +115,20 @@ const fetchBluOsStatus = ( .text() } +/** + * When playing from a file, the BluOS API returns the track name in the `name` field. + * When playing from a streaming service, the BluOS API returns the track name in the `title1` field. + * When playing from certain network radios, the BluOS API returns the track name in the `title2` field, like so + * ``` + * Radio Stream + * Title + * Artist • Album + * ``` + **/ +const resolveTrackName = (t: PlayingBluOsTrack): string => + t.name ?? + (t.artist === t.title2 && t.album === t.title3 ? t.title1 : t.title2) + const parseBluOsStatus = (bluOsXml: string): StatusQueryResponse => { const parsedJs = xml2js(bluOsXml, { compact: true }) @@ -106,7 +136,19 @@ const parseBluOsStatus = (bluOsXml: string): StatusQueryResponse => { const parsedData = xmlJsStatus.safeParse(parsedJs) if (parsedData.success) { - return { etag, playingTrack: parsedData.data } + const { artist, album, secs, totalLength, state } = parsedData.data + const title = resolveTrackName(parsedData.data) + return { + etag, + playingTrack: { + artist, + album, + title, + secs, + totalLength, + state, + }, + } } else { return { etag } } diff --git a/src/submitTrack.ts b/src/submitTrack.ts index afc3bf9..003759c 100644 --- a/src/submitTrack.ts +++ b/src/submitTrack.ts @@ -76,7 +76,7 @@ export const scrobbleTrack = ( artist: t.artist, album: t.album, track: t.title, - duration: t.totalLength, + ...(!!t.totalLength && { duration: t.totalLength }), timestamp: Math.floor(Date.now() / 1000), }), ).pipe( diff --git a/test/scrobbler.test.ts b/test/scrobbler.test.ts index 7bea252..4a64bee 100644 --- a/test/scrobbler.test.ts +++ b/test/scrobbler.test.ts @@ -6,7 +6,7 @@ import { Configuration } from "../src/configuration.js" import { pino } from "pino" import { createLastFmApi } from "../src/lastFm.js" import { assertObservableResults } from "./util/rxUtil.js" -import { trackPlayingResponse } from "./util/bluOsUtil.js" +import { trackRadioResponse, trackStreamingResponse } from "./util/bluOsUtil.js" const createTestScrobbler = () => { const config: Configuration = { @@ -59,7 +59,7 @@ describe("Scrobbler", () => { }) .reply( 200, - trackPlayingResponse({ + trackStreamingResponse({ artist: "Rättö ja Lehtisalo", album: "Valon nopeus", title: "Valonnopeus", @@ -120,6 +120,64 @@ describe("Scrobbler", () => { ]) }) + it("should support radio style track properly", async () => { + nock("http://10.0.0.10:11000") + .get("/Status") + .query({ + timeout: "100", + }) + .reply(200, trackRadioResponse) + nock("https://ws.audioscrobbler.com") + .post( + "/2.0", + "artist=God+Is+an+Astronaut&album=Embers&track=Heart+of+Roots&method=track.updateNowPlaying&api_key=api-key&sk=SESSIONTOKEN&api_sig=aaa05f859af8f51a23257b209c2ce0db&format=json", + ) + .matchHeader("content-type", "application/x-www-form-urlencoded") + .matchHeader("accept", "application/json") + .reply(200, { + nowplaying: { + artist: { corrected: "0", "#text": "God Is an Astronaut" }, + track: { corrected: "0", "#text": "Heart of Roots" }, + ignoredMessage: { code: "0", "#text": "" }, + albumArtist: { corrected: "0", "#text": "" }, + album: { corrected: "0", "#text": "Embers" }, + }, + }) + const { updatedNowPlayingTrack } = await createTestScrobbler() + await assertObservableResults(updatedNowPlayingTrack, [ + { + type: "success", + result: { + type: "known", + value: { + nowplaying: { + artist: { + value: "God Is an Astronaut", + corrected: false, + }, + album: { + value: "Embers", + corrected: false, + }, + albumArtist: { + value: "", + corrected: false, + }, + track: { + value: "Heart of Roots", + corrected: false, + }, + ignoredMessage: { + code: "0", + value: "", + }, + }, + }, + }, + }, + ]) + }) + it("should update now playing track only once if there are many events", async () => { nock("http://10.0.0.10:11000") .get("/Status") @@ -128,7 +186,7 @@ describe("Scrobbler", () => { }) .reply( 200, - trackPlayingResponse({ + trackStreamingResponse({ artist: "Convextion", album: "R-CNVX2", title: "Ebulience", @@ -146,7 +204,7 @@ describe("Scrobbler", () => { }) .reply( 200, - trackPlayingResponse({ + trackStreamingResponse({ artist: "Convextion", album: "R-CNVX2", title: "Ebulience", @@ -215,7 +273,7 @@ describe("Scrobbler", () => { }) .reply( 200, - trackPlayingResponse({ + trackStreamingResponse({ artist: "Convextion", album: "R-CNVX2", title: "Ebulience", diff --git a/test/util/bluOsUtil.ts b/test/util/bluOsUtil.ts index 18da340..35336ee 100644 --- a/test/util/bluOsUtil.ts +++ b/test/util/bluOsUtil.ts @@ -1,12 +1,4 @@ -export const trackPlayingResponse = ({ - artist, - album, - title, - secs, - totalLength, - state, - etag, -}: { +interface TrackData { artist: string album: string title: string @@ -14,7 +6,17 @@ export const trackPlayingResponse = ({ totalLength: number state: string etag: string -}): string => +} + +export const trackStreamingResponse = ({ + artist, + album, + title, + secs, + totalLength, + state, + etag, +}: TrackData): string => ` @@ -61,3 +63,50 @@ export const trackPlayingResponse = ({ ${secs} `.trim() + +export const trackRadioResponse: string = ` + + + + + + +Embers +God Is an Astronaut +true +0 +https://img.radioparadise.com/covers/l/19378_7f640f4e-225d-4ed2-8da2-afe623969211.jpg +7 +-100 +https://img.radioparadise.com/covers/l/19378_7f640f4e-225d-4ed2-8da2-afe623969211.jpg +0 +https://en.wikipedia.org/wiki/God_Is_an_Astronaut +RadioParadise +31 +1 +44100 +0 +33 +1 +mqa +2 +34 +RadioParadise +/Sources/images/RadioParadiseIcon.png +Radio Paradise +0 +6 + +0 +stream +https://img.radioparadise.com/source/27/channel_logo/chan_0.png +16/44.1 +RadioParadise:/0:20 +272 +RP Main Mix +Heart of Roots +God Is an Astronaut • Embers +0 +45 + + ` From c1c7b8b89425747050b1125d2869330b4407c84c Mon Sep 17 00:00:00 2001 From: Janne Liuhtonen Date: Sun, 15 Sep 2024 12:20:09 +0300 Subject: [PATCH 2/3] Fix comparing track equality --- src/bluOs/player.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bluOs/player.ts b/src/bluOs/player.ts index 5c383fd..c0a0e72 100644 --- a/src/bluOs/player.ts +++ b/src/bluOs/player.ts @@ -88,7 +88,7 @@ export const isTrackPlaying = (t: PlayingTrack) => trackPlayingStates.includes(t.state) export const isSameTrack = (a: PlayingTrack, b: PlayingTrack): boolean => - a.title === b.title && a.album === b.album && a.title === b.title + a.title === b.title && a.album === b.album && a.artist === b.artist export const hasPlayedOverThreshold = (t: PlayingTrack, threshold: number) => t.secs / longPollTimeoutSecs >= From 60d84ead8f3f4f1858efe21db92e4ec95e10897d Mon Sep 17 00:00:00 2001 From: Janne Liuhtonen Date: Sun, 15 Sep 2024 12:28:00 +0300 Subject: [PATCH 3/3] Add test for a Pandora track --- test/scrobbler.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++- test/util/bluOsUtil.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/test/scrobbler.test.ts b/test/scrobbler.test.ts index 4a64bee..612e2b5 100644 --- a/test/scrobbler.test.ts +++ b/test/scrobbler.test.ts @@ -6,7 +6,11 @@ import { Configuration } from "../src/configuration.js" import { pino } from "pino" import { createLastFmApi } from "../src/lastFm.js" import { assertObservableResults } from "./util/rxUtil.js" -import { trackRadioResponse, trackStreamingResponse } from "./util/bluOsUtil.js" +import { + trackPandoraRadioResponse, + trackRadioResponse, + trackStreamingResponse, +} from "./util/bluOsUtil.js" const createTestScrobbler = () => { const config: Configuration = { @@ -178,6 +182,64 @@ describe("Scrobbler", () => { ]) }) + it("should properly parse Pandora track", async () => { + nock("http://10.0.0.10:11000") + .get("/Status") + .query({ + timeout: "100", + }) + .reply(200, trackPandoraRadioResponse) + nock("https://ws.audioscrobbler.com") + .post( + "/2.0", + "artist=Shaman%27s+Dream&album=Prana+Pulse&track=Nectar&method=track.updateNowPlaying&api_key=api-key&sk=SESSIONTOKEN&api_sig=918c64bf709b5cce947be0f16f9b001e&format=json", + ) + .matchHeader("content-type", "application/x-www-form-urlencoded") + .matchHeader("accept", "application/json") + .reply(200, { + nowplaying: { + artist: { corrected: "0", "#text": "Shaman's Dream" }, + track: { corrected: "0", "#text": "Nectar" }, + ignoredMessage: { code: "0", "#text": "" }, + albumArtist: { corrected: "0", "#text": "" }, + album: { corrected: "0", "#text": "Prana Pulse" }, + }, + }) + const { updatedNowPlayingTrack } = await createTestScrobbler() + await assertObservableResults(updatedNowPlayingTrack, [ + { + type: "success", + result: { + type: "known", + value: { + nowplaying: { + artist: { + value: "Shaman's Dream", + corrected: false, + }, + album: { + value: "Prana Pulse", + corrected: false, + }, + albumArtist: { + value: "", + corrected: false, + }, + track: { + value: "Nectar", + corrected: false, + }, + ignoredMessage: { + code: "0", + value: "", + }, + }, + }, + }, + }, + ]) + }) + it("should update now playing track only once if there are many events", async () => { nock("http://10.0.0.10:11000") .get("/Status") diff --git a/test/util/bluOsUtil.ts b/test/util/bluOsUtil.ts index 35336ee..3097ad4 100644 --- a/test/util/bluOsUtil.ts +++ b/test/util/bluOsUtil.ts @@ -110,3 +110,53 @@ export const trackRadioResponse: string = ` 45 ` + +export const trackPandoraRadioResponse: string = ` + + + + + + + + +Prana Pulse +AL:191326 +Shaman's Dream +AR:213207 +true +https://content-images.p-cdn.com/images/11/92/78/b5/01bd4f0bbf2e3324fe8a6577/_640W_640H.jpg +0 +-28.5 +https://content-images.p-cdn.com/images/11/92/78/b5/01bd4f0bbf2e3324fe8a6577/_640W_640H.jpg +0 +15 +1 +0 +10 +0 +191000 +0 +34 +Pandora +/Sources/images/PandoraIcon.png +Pandora +0 +140 +Pandora:radio:SF:16722:213207 + +0 +stream +https://content-images.p-cdn.com/images/3f/bd/73/31/cd7f42cba6358625dd80d56a/_500W_500H.jpg +MP3 191 kb/s +Pandora:radio:ST:0:138214111317067420 +190 +Shaman's Dream Radio +Nectar +Shaman's Dream • Prana Pulse +445 +Pandora:radio:SF:21586:2166321 +36 +183 + +`