Update scrobble logic

This commit is contained in:
Wolfgang Kulhanek
2025-10-16 15:13:41 +02:00
parent 53d06721fb
commit 4965e2f8df
2 changed files with 78 additions and 58 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ node_modules
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
.pnp.* .pnp.*
log.txt

View File

@@ -605,73 +605,92 @@ function server(
// Sonos Reporting Endpoint for playback analytics // Sonos Reporting Endpoint for playback analytics
app.post("/report/:version/timePlayed", async (req, res) => { app.post("/report/:version/timePlayed", async (req, res) => {
const version = req.params["version"]; const version = req.params["version"];
logger.debug(`Received Sonos reporting event (v${version}):`, JSON.stringify(req.body)); logger.debug(`Received Sonos reporting event (v${version}): ${JSON.stringify(req.body)}`);
try { try {
const { // Sonos may send an array of reports
reportId, const reports = Array.isArray(req.body) ? req.body : [req.body];
mediaUrl,
durationPlayedMillis,
positionMillis,
type,
} = req.body;
// Extract track ID from mediaUrl (format: /stream/track/{id}) for (const report of reports) {
const trackIdMatch = mediaUrl?.match(/\/stream\/track\/([^?]+)/); const {
if (!trackIdMatch) { reportId,
logger.warn(`Could not extract track ID from mediaUrl: ${mediaUrl}`); mediaUrl,
return res.status(200).json({ status: "ok" }); durationPlayedMillis,
} positionMillis,
type,
} = report;
const trackId = trackIdMatch[1]; // Extract track ID from mediaUrl (format: /stream/track/{id} or x-sonos-http:track%3a{id}.mp3)
const durationPlayedSeconds = Math.floor((durationPlayedMillis || 0) / 1000); let trackId: string | undefined;
logger.info( if (mediaUrl) {
`Sonos reporting: type=${type}, trackId=${trackId}, reportId=${reportId}, ` + // Try standard URL format first
`durationPlayed=${durationPlayedSeconds}s, position=${positionMillis}ms` const standardMatch = mediaUrl.match(/\/stream\/track\/([^?]+)/);
); if (standardMatch) {
trackId = standardMatch[1];
// For "final" reports, determine if we should scrobble } else {
if (type === "final" && durationPlayedSeconds > 0) { // Try x-sonos-http format (track%3a{id}.mp3)
// Extract authentication from request headers or body const sonosMatch = mediaUrl.match(/track%3[aA]([^.?&]+)/);
// Sonos may send credentials in Authorization header or in the request if (sonosMatch) {
const authHeader = req.headers["authorization"]; trackId = sonosMatch[1];
let serviceToken: string | undefined; }
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
const smapiToken = serverOpts.smapiTokenStore.get(token);
if (smapiToken) {
serviceToken = pipe(
smapiAuthTokens.verify({ token, key: smapiToken.key }),
E.getOrElseW(() => undefined)
);
} }
} }
if (serviceToken) { if (!trackId) {
await musicService.login(serviceToken).then((musicLibrary) => { logger.warn(`Could not extract track ID from mediaUrl: ${mediaUrl}, full report: ${JSON.stringify(report)}`);
// Get track duration to determine scrobbling threshold continue; // Skip this report, process next one
return musicLibrary.track(trackId).then((track) => { }
const shouldScrobble =
(track.duration < 30 && durationPlayedSeconds >= 10) ||
(track.duration >= 30 && durationPlayedSeconds >= 30);
if (shouldScrobble) { const durationPlayedSeconds = Math.floor((durationPlayedMillis || 0) / 1000);
logger.info(`Scrobbling track ${trackId} after ${durationPlayedSeconds}s playback`);
return musicLibrary.scrobble(trackId); logger.info(
} else { `Sonos reporting: type=${type}, trackId=${trackId}, reportId=${reportId}, ` +
logger.debug( `durationPlayed=${durationPlayedSeconds}s, position=${positionMillis}ms`
`Not scrobbling track ${trackId}: duration=${track.duration}s, played=${durationPlayedSeconds}s` );
);
return Promise.resolve(false); // For "final" reports, determine if we should scrobble
} if (type === "final" && durationPlayedSeconds > 0) {
// Extract authentication from request headers or body
// Sonos may send credentials in Authorization header or in the request
const authHeader = req.headers["authorization"];
let serviceToken: string | undefined;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
const smapiToken = serverOpts.smapiTokenStore.get(token);
if (smapiToken) {
serviceToken = pipe(
smapiAuthTokens.verify({ token, key: smapiToken.key }),
E.getOrElseW(() => undefined)
);
}
}
if (serviceToken) {
await musicService.login(serviceToken).then((musicLibrary) => {
// Get track duration to determine scrobbling threshold
return musicLibrary.track(trackId!).then((track) => {
const shouldScrobble =
(track.duration < 30 && durationPlayedSeconds >= 10) ||
(track.duration >= 30 && durationPlayedSeconds >= 30);
if (shouldScrobble) {
logger.info(`Scrobbling track ${trackId} after ${durationPlayedSeconds}s playback`);
return musicLibrary.scrobble(trackId!);
} else {
logger.debug(
`Not scrobbling track ${trackId}: duration=${track.duration}s, played=${durationPlayedSeconds}s`
);
return Promise.resolve(false);
}
});
}).catch((e) => {
logger.error(`Failed to process scrobble for track ${trackId}`, { error: e });
}); });
}).catch((e) => { } else {
logger.error(`Failed to process scrobble for track ${trackId}`, { error: e }); logger.debug("No authentication available for reporting endpoint scrobble");
}); }
} else {
logger.debug("No authentication available for reporting endpoint scrobble");
} }
} }