Skip to content

Commit

Permalink
Merge pull request #87 from jliuhtonen/fix/resolving-radio-track-names
Browse files Browse the repository at this point in the history
Fix resolving radio tracks
  • Loading branch information
jliuhtonen authored Sep 15, 2024
2 parents c5e7c85 + 60d84ea commit df5edec
Show file tree
Hide file tree
Showing 4 changed files with 284 additions and 23 deletions.
56 changes: 49 additions & 7 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,21 +68,31 @@ 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)

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 >=
(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,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(
Expand Down
130 changes: 125 additions & 5 deletions test/scrobbler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { trackPlayingResponse } from "./util/bluOsUtil.js"
import {
trackPandoraRadioResponse,
trackRadioResponse,
trackStreamingResponse,
} from "./util/bluOsUtil.js"

const createTestScrobbler = () => {
const config: Configuration = {
Expand Down Expand Up @@ -59,7 +63,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Rättö ja Lehtisalo",
album: "Valon nopeus",
title: "Valonnopeus",
Expand Down Expand Up @@ -120,6 +124,122 @@ 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 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")
Expand All @@ -128,7 +248,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Convextion",
album: "R-CNVX2",
title: "Ebulience",
Expand All @@ -146,7 +266,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Convextion",
album: "R-CNVX2",
title: "Ebulience",
Expand Down Expand Up @@ -215,7 +335,7 @@ describe("Scrobbler", () => {
})
.reply(
200,
trackPlayingResponse({
trackStreamingResponse({
artist: "Convextion",
album: "R-CNVX2",
title: "Ebulience",
Expand Down
Loading

0 comments on commit df5edec

Please sign in to comment.