Skip to content

Commit

Permalink
Fix resolving radio tracks
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jliuhtonen committed Sep 13, 2024
1 parent 8f4e177 commit adf9f1f
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 22 deletions.
54 changes: 48 additions & 6 deletions src/bluOs/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))

Expand All @@ -62,11 +68,21 @@ export interface StatusQueryResponse {
playingTrack?: PlayingTrack
}

export type PlayingTrack = zod.infer<typeof xmlJsStatus>
export type PlayingBluOsTrack = zod.infer<typeof xmlJsStatus>

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)
Expand All @@ -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,
Expand All @@ -99,14 +115,40 @@ 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
* ```
* <title1>Radio Stream</title1>
* <title2>Title</title2>
* <title3>Artist • Album</title3>
* ```
**/
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 })

const etag = xmlJsEtag.parse(parsedJs)
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 }
}
Expand Down
2 changes: 1 addition & 1 deletion src/submitTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ export const scrobbleTrack = (
artist: t.artist,
album: t.album,
track: t.title,
duration: t.totalLength,
timestamp: Math.floor(Date.now() / 1000),
...(!!t.totalLength && { duration: t.totalLength }),
}),
).pipe(
retry({ delay: 20000, count: 5 }),
Expand Down
68 changes: 63 additions & 5 deletions test/scrobbler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -59,7 +59,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Rättö ja Lehtisalo",
album: "Valon nopeus",
title: "Valonnopeus",
Expand Down Expand Up @@ -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")
Expand All @@ -128,7 +186,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Convextion",
album: "R-CNVX2",
title: "Ebulience",
Expand All @@ -146,7 +204,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Convextion",
album: "R-CNVX2",
title: "Ebulience",
Expand Down Expand Up @@ -215,7 +273,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Convextion",
album: "R-CNVX2",
title: "Ebulience",
Expand Down
69 changes: 59 additions & 10 deletions test/util/bluOsUtil.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
export const trackPlayingResponse = ({
artist,
album,
title,
secs,
totalLength,
state,
etag,
}: {
interface TrackData {
artist: string
album: string
title: string
secs: number
totalLength: number
state: string
etag: string
}): string =>
}

export const trackStreamingResponse = ({
artist,
album,
title,
secs,
totalLength,
state,
etag,
}: TrackData): string =>
`
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<status etag="${etag}">
Expand Down Expand Up @@ -61,3 +63,50 @@ export const trackPlayingResponse = ({
<secs>${secs}</secs>
</status>
`.trim()

export const trackRadioResponse: string = `
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<status etag="d5748f55e3f800561066f5fd7d0529de">
<actions>
<action name="back" />
<action name="skip" url="/Action?service=RadioParadise&amp;next=2628777" />
</actions>
<album>Embers</album>
<artist>God Is an Astronaut</artist>
<canMovePlayback>true</canMovePlayback>
<canSeek>0</canSeek>
<currentImage>https://img.radioparadise.com/covers/l/19378_7f640f4e-225d-4ed2-8da2-afe623969211.jpg</currentImage>
<cursor>7</cursor>
<db>-100</db>
<image>https://img.radioparadise.com/covers/l/19378_7f640f4e-225d-4ed2-8da2-afe623969211.jpg</image>
<indexing>0</indexing>
<infourl>https://en.wikipedia.org/wiki/God_Is_an_Astronaut</infourl>
<inputId>RadioParadise</inputId>
<mid>31</mid>
<mode>1</mode>
<mqaOFS>44100</mqaOFS>
<mute>0</mute>
<pid>33</pid>
<prid>1</prid>
<quality>mqa</quality>
<repeat>2</repeat>
<schemaVersion>34</schemaVersion>
<service>RadioParadise</service>
<serviceIcon>/Sources/images/RadioParadiseIcon.png</serviceIcon>
<serviceName>Radio Paradise</serviceName>
<shuffle>0</shuffle>
<sid>6</sid>
<sleep></sleep>
<song>0</song>
<state>stream</state>
<stationImage>https://img.radioparadise.com/source/27/channel_logo/chan_0.png</stationImage>
<streamFormat>16/44.1</streamFormat>
<streamUrl>RadioParadise:/0:20</streamUrl>
<syncStat>272</syncStat>
<title1>RP Main Mix</title1>
<title2>Heart of Roots</title2>
<title3>God Is an Astronaut • Embers</title3>
<volume>0</volume>
<secs>45</secs>
</status>
`

0 comments on commit adf9f1f

Please sign in to comment.