[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #460 from FoxxMD/playDiff

feat: Implement play diffing with inputs for debugging

authored by

Matt Foxx and committed by
GitHub
(Jan 29, 2026, 5:43 PM EST) 205d626c 62b06c0f

+861 -243
+19 -1
.github/ISSUE_TEMPLATE/01-bug-report.yml
··· 7 7 attributes: 8 8 label: Please check existing knowledge before opening an issue 9 9 options: 10 + - label: I have [read the Help docs](https://foxxmd.github.io/multi-scrobbler/docs/help/) 11 + required: true 10 12 - label: I have [checked the FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ/) and [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/) 11 13 required: true 12 14 - label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions) ··· 62 64 description: | 63 65 Please copy and paste any/all relevant from the DEBUG level log output. 64 66 Even if your issue does not seem to be shown in the logs you should still add as much detail as possible, a _lack_ of issues in logs may be a good hint during debugging. 65 - Your logs will be automatically formatted into code, do not need use backticks. 67 + Your logs will be automatically formatted into code. Do not use fenced code blocks (backticks). 66 68 If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode). 67 69 render: shell 68 70 placeholder: | ··· 71 73 [2025-02-14 12:47:57.258 -0500] INFO : [Init] Version: master 72 74 [2025-02-14 12:47:57.259 -0500] INFO : [Init] Generating schema definitions... 73 75 [2025-02-14 12:47:59.148 -0500] DEBUG : [App] [Scrobblers] [Maloja - default] Found 100 recent scrobbles 76 + - type: textarea 77 + id: playDebug 78 + attributes: 79 + label: Play Debug Data 80 + description: | 81 + If relevent, please copy and paste all [**Debug Data**](https://foxxmd.github.io/multi-scrobbler/help#copy-play-debug-data) related to the Plays you are having issues with. 82 + Your data will be automatically formatted into code. Do not use fenced code blocks (backticks) in the text area below. 83 + render: shell 84 + placeholder: | 85 + { 86 + "input": { 87 + "addedAt": 1690250024, 88 + "art": "/library/metadata/186983/art/1698213665", 89 + "duration": 243983, 90 + "grandparentArt": "/library/metadata/186983/art/1698213665", 91 + ... 74 92 - type: textarea 75 93 attributes: 76 94 label: Additional Context
+71
docsite/docs/help.mdx
··· 1 + --- 2 + title: 'Help' 3 + --- 4 + 5 + ## Getting Useful Data 6 + 7 + ### Logs 8 + 9 + Multi-scrobbeler has a real-time log shown in the dashboard. These are the same logs you would find in docker logs. In the dashboard, logs can be filtered by LEVELS to give you a better view of errors/warnings. 10 + 11 + When creating an issue please include all logs at the lowest level possible (DEBUG) to help provide context for your issue. You should try to include logs that occurred *before* and *after* your issue/error occurs as this can help determine if MS was behaving normally. 12 + 13 + ### Debug Mode 14 + 15 + If possible, turn on [**Debug Mode**](/configuration#debug-mode) and replicate your issue. Debug Mode enables *much more* logging that can reveal additional data for troubleshooting. 16 + 17 + This mode will make your logs very noisy, though, so it's recommended to turn it off after collecting logs. 18 + 19 + ### Copy Play Debug Data 20 + 21 + From the dashboard, each Source/Client has a few details page links: 22 + 23 + <img src={require('/img/details.jpg').default} height="400"/> 24 + 25 + Opening the details page for the Source/Client reveals a lists all of the Plays that were discovered (Sources) or scrobbled (Clients). 26 + 27 + Click on the Debug icon to copy debugging data relevant to that Play. This data should be included in any issue you create. 28 + 29 + <img src={require('/img/debug_button.jpg').default} height="400"/> 30 + 31 + To include this data in an issue either paste the copied data into a text file and save as `debugdata.json`, attaching to the issue. 32 + 33 + Or directly paste the copied data into an issue/comment using [**fenced code blocks**](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#fenced-code-blocks) markdown to preserve the structure like: 34 + 35 + ```` 36 + This is the contents of my comment/issue text on github. 37 + 38 + I am explaining my issue here. Here is the debug data: 39 + 40 + ```json 41 + { 42 + "input": { 43 + "aProperty: 123, 44 + "foo": "bar" 45 + } 46 + } 47 + ``` 48 + 49 + Thank you for your time. 50 + 51 + ```` 52 + 53 + ## Finding Help 54 + 55 + ### FAQ 56 + 57 + Check the [FAQ](/faq) to see if your issue is a known issue or a commonly-experienced issue with Multi-Scrobbler. The FAQ also includes fixes or ways to help troubleshoot these common issues. 58 + 59 + ### Existing Issues 60 + 61 + On the github repository for Multi-Scrobbler, search [existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions](https://github.com/FoxxMD/multi-scrobbler/discussions) to see if anyone else has had this issue and if it has already been solved. 62 + 63 + ## Reporting an Issue 64 + 65 + Before reporting an issue please consider: 66 + 67 + * If this a how-do-I question, troublehooting, or generally **not** a bug/error with multi-scrobbler please consider [**starting a discussion**](https://github.com/FoxxMD/multi-scrobbler/discussions) instead of opening an issue 68 + * If you want to request new functionality use the [**Feature Request issue** template](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=02-feature-request.yml) 69 + * If you want to request a new Source/Client implementation use the [**New Source/Client issue** template](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=03-new-source-client.yml) 70 + 71 + If none of the above apply to you then create a new [**bug report**](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=01-bug-report.yml). Please include all [logs](#logs) and [play debug data](#copy-play-debug-data) relevant to your issue. The more logs the better. If possible, replicate the issue with [**debug mode** turned on](#debug-mode).
docsite/static/img/debug_button.jpg

This is a binary file and will not be displayed.

docsite/static/img/details.jpg

This is a binary file and will not be displayed.

+78
package-lock.json
··· 69 69 "iti": "^0.7.0", 70 70 "jest-diff": "^30.2.0", 71 71 "json5": "^2.2.3", 72 + "jsondiffpatch": "^0.7.3", 72 73 "keyv": "^5.5.0", 73 74 "kodi-api": "^0.2.1", 74 75 "lastfm-ts-api": "^2.5.0", ··· 92 93 "react-router-dom": "^6.15.0", 93 94 "round-robin-js": "^3.0.10", 94 95 "sass": "^1.93.3", 96 + "serialize-error": "^13.0.1", 95 97 "spotify-web-api-node": "^5.0.2", 96 98 "superagent": "^8.0.9", 97 99 "tailwindcss": "^4.1.16", ··· 1069 1071 "dependencies": { 1070 1072 "@dbus-types/dbus": "^0.0.4" 1071 1073 } 1074 + }, 1075 + "node_modules/@dmsnell/diff-match-patch": { 1076 + "version": "1.1.0", 1077 + "resolved": "https://registry.npmjs.org/@dmsnell/diff-match-patch/-/diff-match-patch-1.1.0.tgz", 1078 + "integrity": "sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==", 1079 + "license": "Apache-2.0" 1072 1080 }, 1073 1081 "node_modules/@donedeal0/superdiff": { 1074 1082 "version": "1.1.3", ··· 8277 8285 "node": ">=6" 8278 8286 } 8279 8287 }, 8288 + "node_modules/jsondiffpatch": { 8289 + "version": "0.7.3", 8290 + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.7.3.tgz", 8291 + "integrity": "sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==", 8292 + "license": "MIT", 8293 + "dependencies": { 8294 + "@dmsnell/diff-match-patch": "^1.1.0" 8295 + }, 8296 + "bin": { 8297 + "jsondiffpatch": "bin/jsondiffpatch.js" 8298 + }, 8299 + "engines": { 8300 + "node": "^18.0.0 || >=20.0.0" 8301 + } 8302 + }, 8280 8303 "node_modules/jsonfile": { 8281 8304 "version": "6.1.0", 8282 8305 "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", ··· 9453 9476 }, 9454 9477 "engines": { 9455 9478 "node": ">=4" 9479 + } 9480 + }, 9481 + "node_modules/non-error": { 9482 + "version": "0.1.0", 9483 + "resolved": "https://registry.npmjs.org/non-error/-/non-error-0.1.0.tgz", 9484 + "integrity": "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==", 9485 + "license": "MIT", 9486 + "engines": { 9487 + "node": ">=20" 9488 + }, 9489 + "funding": { 9490 + "url": "https://github.com/sponsors/sindresorhus" 9456 9491 } 9457 9492 }, 9458 9493 "node_modules/normalize-exception": { ··· 11459 11494 "node": ">= 0.8" 11460 11495 } 11461 11496 }, 11497 + "node_modules/serialize-error": { 11498 + "version": "13.0.1", 11499 + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-13.0.1.tgz", 11500 + "integrity": "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==", 11501 + "license": "MIT", 11502 + "dependencies": { 11503 + "non-error": "^0.1.0", 11504 + "type-fest": "^5.4.1" 11505 + }, 11506 + "engines": { 11507 + "node": ">=20" 11508 + }, 11509 + "funding": { 11510 + "url": "https://github.com/sponsors/sindresorhus" 11511 + } 11512 + }, 11513 + "node_modules/serialize-error/node_modules/type-fest": { 11514 + "version": "5.4.2", 11515 + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.2.tgz", 11516 + "integrity": "sha512-FLEenlVYf7Zcd34ISMLo3ZzRE1gRjY1nMDTp+bQRBiPsaKyIW8K3Zr99ioHDUgA9OGuGGJPyYpNcffGmBhJfGg==", 11517 + "license": "(MIT OR CC0-1.0)", 11518 + "dependencies": { 11519 + "tagged-tag": "^1.0.0" 11520 + }, 11521 + "engines": { 11522 + "node": ">=20" 11523 + }, 11524 + "funding": { 11525 + "url": "https://github.com/sponsors/sindresorhus" 11526 + } 11527 + }, 11462 11528 "node_modules/serialize-javascript": { 11463 11529 "version": "6.0.2", 11464 11530 "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", ··· 11998 12064 }, 11999 12065 "engines": { 12000 12066 "node": ">=8" 12067 + } 12068 + }, 12069 + "node_modules/tagged-tag": { 12070 + "version": "1.0.0", 12071 + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", 12072 + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", 12073 + "license": "MIT", 12074 + "engines": { 12075 + "node": ">=20" 12076 + }, 12077 + "funding": { 12078 + "url": "https://github.com/sponsors/sindresorhus" 12001 12079 } 12002 12080 }, 12003 12081 "node_modules/tailwindcss": {
+2
package.json
··· 103 103 "iti": "^0.7.0", 104 104 "jest-diff": "^30.2.0", 105 105 "json5": "^2.2.3", 106 + "jsondiffpatch": "^0.7.3", 106 107 "keyv": "^5.5.0", 107 108 "kodi-api": "^0.2.1", 108 109 "lastfm-ts-api": "^2.5.0", ··· 126 127 "react-router-dom": "^6.15.0", 127 128 "round-robin-js": "^3.0.10", 128 129 "sass": "^1.93.3", 130 + "serialize-error": "^13.0.1", 129 131 "spotify-web-api-node": "^5.0.2", 130 132 "superagent": "^8.0.9", 131 133 "tailwindcss": "^4.1.16",
+50 -21
src/backend/common/AbstractComponent.ts
··· 27 27 import { hashObject } from "../utils/StringUtils.js"; 28 28 import { metaInvariantTransform, playContentInvariantTransform } from "../utils/PlayComparisonUtils.js"; 29 29 import { MSCache } from "./Cache.js"; 30 + import { jdiff } from "../utils/DataUtils.js"; 31 + import ConsoleFormatter from "jsondiffpatch/formatters/console"; 32 + 33 + const console = new ConsoleFormatter(); 30 34 31 35 export default abstract class AbstractComponent extends AbstractInitializable { 32 36 ··· 240 244 break; 241 245 } 242 246 } else if(onFailure === 'continue') { 243 - logger.warn(merged, 'A transform encountered an error but continuing due to onFailure: continue'); 247 + logger.warn(merged, 'A transform encounte[red an error but continuing due to onFailure: continue'); 244 248 } else { 245 249 const reqError = findCauseByReference(err, StagePrerequisiteError); 246 250 if(reqError !== undefined) { ··· 272 276 } 273 277 } 274 278 275 - if(transformedPlay.meta.transforms === undefined) { 276 - transformedPlay.meta.transforms = { 277 - original: play.data 278 - }; 279 - } 279 + const orig = play.data; 280 + 281 + // if(transformedPlay.meta.transforms === undefined) { 282 + // transformedPlay.meta.transforms = { 283 + // original: play.data 284 + // }; 285 + // } 286 + 287 + const o = JSON.parse(JSON.stringify(play)); 288 + const t = JSON.parse(JSON.stringify(transformedPlay)); 289 + const patch = jdiff.diff(o,t); 290 + 280 291 if(shouldLog !== false) { 281 292 if(transformHistory.length === 0) { 282 293 logger.debug('Transform Diff: No Change'); 283 294 } else { 284 295 const historyToDiff: {name: string, data: PlayData}[] = [ 285 - {name: 'Original', data: transformedPlay.meta.transforms.original} 296 + {name: 'Original', data: play.data} 286 297 ]; 287 298 if(shouldLog === true) { 288 299 const last = transformHistory[transformHistory.length - 1]; ··· 301 312 if(deepEqual(last.data, curr.data)) { 302 313 diffs.push(`${last.name} => ${curr.name} -- No Change`); 303 314 } else { 304 - diffs.push(diffStringsUnified( 305 - buildPlayHumanDiffable(last.data, {expandMeta: true}), 306 - buildPlayHumanDiffable(curr.data, {expandMeta: true}), 307 - { 308 - aAnnotation: last.name, 309 - aColor: chalk.red, 310 - bAnnotation: curr.name, 311 - bColor: chalk.green 312 - } 313 - )) 315 + const unchanged = index - 1 === 0 ? last.data : undefined; 316 + 317 + const formattedDiff = console.format(jdiff.diff(last.data, curr.data), unchanged); 318 + diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`); 314 319 } 315 320 }); 316 - logger.debug(`Transform Diff\n${diffs}`); 321 + logger.debug(`Transform Diff\n${diffs.join('\n')}`); 317 322 } 323 + } 318 324 325 + const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.name === 'hookType' && x.source === this.getIdentifier()); 326 + if(existingStepIndex !== -1) { 327 + transformedPlay.meta.lifecycle.steps[existingStepIndex].patch = patch; 328 + transformedPlay.meta.lifecycle.steps[existingStepIndex].inputs = transformedPlay.meta.lifecycleInputs; 329 + } else { 330 + transformedPlay.meta.lifecycle.steps.push({ 331 + name: hookType, 332 + source: this.getIdentifier(), 333 + patch: patch, 334 + inputs: transformedPlay.meta.lifecycleInputs 335 + }); 319 336 } 320 - const previousHistory = transformedPlay.meta.transforms[hookType] ?? []; 321 - transformedPlay.meta.transforms[hookType] = [...previousHistory, ...transformHistory]; 337 + delete transformedPlay.meta.lifecycleInputs; 338 + 339 + // transformedPlay.meta.lifecycle = { 340 + // ...(transformedPlay.meta.lifecycle ?? { 341 + // original: play 342 + // }), 343 + // steps: [ 344 + // ...(transformedPlay.meta.lifecycle?.steps ?? []), 345 + // { 346 + // name: hookType, 347 + // source: this.getIdentifier(), 348 + // patch: patch 349 + // } 350 + // ] 351 + // } 322 352 await this.cache.cacheTransform.set<PlayObject>(transformHash, transformedPlay, '5m'); 323 - //logger.debug(`Cache set ${transformHash}`) 324 353 return transformedPlay; 325 354 } catch (e) { 326 355 logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
+10
src/backend/common/errors/MSErrors.ts
··· 1 1 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 2 2 import mergeErrorCause from 'merge-error-cause'; 3 3 import { findCauseByFunc, findCauseByReference } from "../../utils/ErrorUtils.js"; 4 + import { UpstreamError, UpstreamErrorOptions } from "./UpstreamError.js"; 4 5 5 6 export abstract class NamedError extends Error { 6 7 public abstract name: string; ··· 94 95 return mergeErrorCause(structuredClone(err)); 95 96 } 96 97 return err; 98 + } 99 + 100 + export class ScrobbleSubmitError<T extends (object | string) = object> extends UpstreamError { 101 + name = 'Scrobble Submit Error'; 102 + payload?: T; 103 + constructor(message: string, options?: UpstreamErrorOptions & {payload?: T}) { 104 + super(message, options); 105 + this.payload = options?.payload; 106 + } 97 107 }
+13 -5
src/backend/common/errors/UpstreamError.ts
··· 2 2 3 3 import { findCauseByFunc } from "../../utils/ErrorUtils.js"; 4 4 5 - export class UpstreamError<T = undefined> extends Error { 5 + export type UpstreamErrorOptions = ErrorOptions & { showStopper?: boolean, response?: Response, responseBody?: object | string }; 6 + 7 + export class UpstreamError extends Error { 6 8 7 9 showStopper: boolean = false; 8 10 response?: Response 11 + responseBody?: object | string 9 12 10 - constructor(message: string, options?: { cause?: T, showStopper?: boolean, response?: Response } | undefined) { 13 + constructor(message: string, options?: UpstreamErrorOptions | undefined) { 11 14 super(message, options); 12 - const {showStopper = false, response} = options; 15 + const {showStopper = false, response, responseBody} = options; 13 16 this.showStopper = showStopper; 14 17 this.response = response; 18 + this.responseBody = responseBody; 15 19 } 16 20 } 17 21 18 22 export const hasUpstreamError = (err: any, showStopping?: boolean): boolean => { 23 + return findUpstreamError(err, showStopping) !== undefined; 24 + } 25 + 26 + export const findUpstreamError = (err: any, showStopping?: boolean): UpstreamError | undefined => { 19 27 return findCauseByFunc(err, (e) => { 20 28 if (e instanceof UpstreamError) { 21 29 if (showStopping === undefined) { ··· 25 33 } 26 34 } 27 35 return false; 28 - }) !== undefined; 29 - } 36 + }); 37 + }
+3 -2
src/backend/common/infrastructure/Atomic.ts
··· 3 3 import { Request, Response } from "express"; 4 4 import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core"; 5 5 import { FixedSizeList } from 'fixed-size-list'; 6 - import { isPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js"; 6 + import { isPlayObject, PlayMeta, PlayObject, PlayObjectLifecycleless } from "../../../core/Atomic.js"; 7 7 import TupleMap from "../TupleMap.js"; 8 8 import { MusicBrainzApi } from 'musicbrainz-api'; 9 9 ··· 242 242 243 243 export interface ScrobbledPlayObject { 244 244 play: PlayObject 245 - scrobble: PlayObject 245 + scrobble: PlayObjectLifecycleless 246 246 } 247 247 248 248 ··· 397 397 } 398 398 399 399 export const MUSICBRAINZ_URL = 'https://musicbrainz.org'; 400 + export const MBID_VARIOUS_ARTISTS = "89ad4ac3-39f7-470e-963a-56509c546377"; 400 401 401 402 export type MusicBrainzSingletonMap = Map<string,MusicBrainzApi>;
+10 -1
src/backend/common/transforms/AtomicPartsTransformer.ts
··· 1 - import { ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; 1 + import { isPlayObject, ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; 2 2 import { AtomicStageConfig, StageConfig } from "../infrastructure/Transform.js"; 3 3 import AbstractTransformer from "./AbstractTransformer.js"; 4 4 ··· 120 120 ...transformedPlayData, 121 121 meta: mergedMeta 122 122 } 123 + } 124 + 125 + if(typeof transformData === 'object' && isPlayObject(transformData) && transformData.meta?.lifecycleInputs !== undefined) { 126 + const { 127 + meta: { 128 + lifecycleInputs = [], 129 + } = {}, 130 + } = transformedPlay; 131 + transformedPlay.meta.lifecycleInputs = lifecycleInputs.concat(transformData.meta?.lifecycleInputs); 123 132 } 124 133 125 134 return transformedPlay;
+4 -1
src/backend/common/transforms/MusicbrainzTransformer.ts
··· 192 192 export interface IRecordingMSList extends IRecordingList { 193 193 recordings: RecordingRankedMatched[] 194 194 freeText?: boolean 195 + requestQuery: string 195 196 } 196 197 197 198 export const parseStageConfig = (data: MusicbrainzTransformerData | undefined = {}, logger: MaybeLogger = new MaybeLogger()): MusicbrainzTransformerDataStrong => { ··· 636 637 637 638 this.logger.debug(`${filteredList.length} of ${transformData.count} were valid, filtered matches. Using match with best score of ${filteredList[0].score}`); 638 639 639 - return recordingToPlay(filteredList[0], {ignoreVA: stageConfig.ignoreVA}); 640 + const recordingPlay = recordingToPlay(filteredList[0], {ignoreVA: stageConfig.ignoreVA}); 641 + recordingPlay.meta.lifecycleInputs = [...(recordingPlay.meta.lifecycleInputs ?? []), {type: 'mbQuery', input: transformData.requestQuery}, {type: 'mbRecording', input: filteredList[0]}]; 642 + return recordingPlay; 640 643 } 641 644 642 645 protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<string | undefined> {
+4 -2
src/backend/common/vendor/KodiApiClient.ts
··· 2 2 import { KodiClient } from 'kodi-api' 3 3 import normalizeUrl from "normalize-url"; 4 4 import { URL } from "url"; 5 - import { PlayObject } from "../../../core/Atomic.js"; 5 + import { PlayObject, PlayObjectLifecycleless } from "../../../core/Atomic.js"; 6 6 import { RecentlyPlayedOptions } from "../../sources/AbstractSource.js"; 7 7 import { AbstractApiOptions, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; 8 8 import { KodiData } from "../infrastructure/config/source/kodi.js"; 9 9 import AbstractApiClient from "./AbstractApiClient.js"; 10 + import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; 10 11 11 12 interface KodiDuration { 12 13 hours: number ··· 104 105 const album = albumVal === null || albumVal === '' ? undefined : albumVal; 105 106 const trackProgressPosition = time !== undefined ? Math.round(dayjs.duration(time).asSeconds()) : undefined; 106 107 107 - return { 108 + const play: PlayObjectLifecycleless = { 108 109 data: { 109 110 track: title, 110 111 album: album, ··· 123 124 deviceId: playerid !== undefined ? `Player${playerid}` : undefined, 124 125 } 125 126 } 127 + return baseFormatPlayObj(obj, play); 126 128 } 127 129 128 130 testConnection = async () => {
+13 -9
src/backend/common/vendor/LastfmApiClient.ts
··· 1 1 import dayjs, { Dayjs } from "dayjs"; 2 - import { BrainzMeta, PlayObject, URLData } from "../../../core/Atomic.js"; 2 + import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../core/Atomic.js"; 3 3 import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; 4 4 import { removeUndefinedKeys, sleep, writeFile } from "../../utils.js"; 5 5 import { objectIsEmpty, readJson } from '../../utils/DataUtils.js'; ··· 14 14 import { LastFMUser, LastFMAuth, LastFMTrack, LastFMUserGetRecentTracksResponse, LastFMBooleanNumber, LastFMUpdateNowPlayingResponse, LastFMUserGetInfoResponse } from 'lastfm-ts-api'; 15 15 import clone from 'clone'; 16 16 import { IncomingMessage } from "http"; 17 + import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; 18 + import { ScrobbleSubmitError } from "../errors/MSErrors.js"; 17 19 18 20 const badErrors = [ 19 21 'api key suspended', ··· 303 305 } 304 306 } 305 307 306 - scrobble = async (playObj: PlayObject): Promise<PlayObject> => { 308 + scrobble = async (playObj: PlayObject): Promise<ScrobbleActionResult> => { 307 309 const { 308 310 meta: { 309 311 source, ··· 355 357 modifiedPlay.data.album = albumName; 356 358 } 357 359 358 - return modifiedPlay; 360 + return {payload: scrobblePayload, response, mergedScrobble: modifiedPlay}; 359 361 // last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe 360 362 //await sleep(1000); 361 363 } catch (e) { 364 + let apiError: Error; 362 365 if(!(e instanceof UpstreamError)) { 363 - throw new UpstreamError(`Error received from ${this.upstreamName} API`, {cause: e, showStopper: true}); 366 + apiError = new UpstreamError(`Error received from ${this.upstreamName} API`, {cause: e, showStopper: true}); 364 367 } else { 365 - throw e; 368 + apiError = e; 366 369 } 370 + throw new ScrobbleSubmitError('Failed to submit scrobble to Last.fm', {cause: apiError, payload: scrobblePayload}); 367 371 } finally { 368 372 this.logger.debug({payload: scrobblePayload}, 'Raw Payload'); 369 373 } ··· 438 442 artists = [artist]; 439 443 } 440 444 441 - const play: PlayObject = { 445 + const play: PlayObjectLifecycleless = { 442 446 data: { 443 447 track, 444 448 album: nonEmptyStringOrDefault(album), ··· 461 465 }; 462 466 } 463 467 464 - return play; 468 + return baseFormatPlayObj(obj, play); 465 469 } 466 470 467 471 export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload => { ··· 564 568 recording: nonEmptyStringOrDefault<undefined>(mbid) 565 569 }); 566 570 567 - const play: PlayObject = { 571 + const play: PlayObjectLifecycleless = { 568 572 data: { 569 573 artists: [...new Set(artistStrings)] as string[], 570 574 track: title, ··· 587 591 brainz 588 592 } 589 593 } 590 - return play; 594 + return baseFormatPlayObj(obj, play); 591 595 } 592 596 593 597 type LastFMTrackScrobbleResponse = Readonly<{
+20 -12
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 1 1 import { stringSameness } from '@foxxmd/string-sameness'; 2 2 import dayjs from "dayjs"; 3 3 import request, { Request, Response } from 'superagent'; 4 - import { BrainzMeta, PlayObject, URLData } from "../../../core/Atomic.js"; 4 + import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../core/Atomic.js"; 5 5 import { combinePartsToString, slice } from "../../../core/StringUtils.js"; 6 6 import { 7 7 findDelimiters, ··· 23 23 import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js'; 24 24 import { version } from '../../ioc.js'; 25 25 import { ListenPayload, ListenResponse, ListenType, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from './listenbrainz/interfaces.js'; 26 + import { baseFormatPlayObj } from '../../utils/PlayTransformUtils.js'; 27 + import { ScrobbleSubmitError } from '../errors/MSErrors.js'; 26 28 27 29 interface SubmitOptions { 28 30 log?: boolean ··· 224 226 } 225 227 226 228 227 - submitListen = async (play: PlayObject, options: SubmitOptions = {}) => { 228 - const { log = false, listenType = 'single'} = options; 229 + submitListen = async (play: PlayObject, options: SubmitOptions = {}): Promise<ScrobbleActionResult> => { 230 + const listenPayload = playToSubmitPayload(play, {listenType: options.listenType}); 231 + const { log = false} = options; 229 232 try { 230 - const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]}; 231 - if(listenType === 'playing_now') { 232 - delete listenPayload.payload[0].listened_at; 233 - } 234 233 if(log) { 235 234 this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`); 236 235 } ··· 242 241 if(log) { 243 242 this.logger.debug(`Submit Response: ${resp.text}`) 244 243 } 245 - return listenPayload; 244 + return {payload: listenPayload, response: resp.text}; 246 245 } catch (e) { 247 - throw e; 246 + throw new ScrobbleSubmitError(`Failed to submit to Listenbrainz (listen_type ${listenPayload.listen_type})`, {cause: e, payload: listenPayload, response: e.response, responseBody: e.response?.text}); 248 247 } 249 248 } 250 249 ··· 555 554 } 556 555 } 557 556 558 - return play; 557 + return baseFormatPlayObj(listen, play); 559 558 } 560 559 561 560 /** ··· 636 635 } 637 636 638 637 639 - const play: PlayObject = { 638 + const play: PlayObjectLifecycleless = { 640 639 data: { 641 640 playDate: dayjs.unix(listened_at), 642 641 track: normalTrackName, ··· 681 680 } 682 681 } 683 682 684 - return play; 683 + return baseFormatPlayObj(listen, play); 685 684 } 686 685 687 686 ··· 818 817 } 819 818 } 820 819 return undefined; 820 + } 821 + 822 + export const playToSubmitPayload = (play: PlayObject, options: SubmitOptions = {}): SubmitPayload => { 823 + const { listenType = 'single'} = options; 824 + const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]}; 825 + if(listenType === 'playing_now') { 826 + delete listenPayload.payload[0].listened_at; 827 + } 828 + return listenPayload; 821 829 }
+19 -17
src/backend/common/vendor/RockSkyApiClient.ts
··· 1 1 import dayjs from "dayjs"; 2 2 import request, { Request, Response } from 'superagent'; 3 - import { PlayObject, URLData } from "../../../core/Atomic.js"; 3 + import { PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../core/Atomic.js"; 4 4 import { nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; 5 5 import { UpstreamError } from "../errors/UpstreamError.js"; 6 6 import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; ··· 13 13 import { RockskyScrobble } from './rocksky/interfaces.js'; 14 14 import { Handle } from "@atcute/lexicons"; 15 15 import { identifierToAtProtoHandle } from './bluesky/bsUtils.js'; 16 + import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; 17 + import { ScrobbleSubmitError } from "../errors/MSErrors.js"; 16 18 17 19 interface SubmitOptions { 18 20 log?: boolean ··· 170 172 } 171 173 172 174 173 - submitListen = async (play: PlayObject, options: SubmitOptions = {}) => { 175 + submitListen = async (play: PlayObject, options: SubmitOptions = {}): Promise<ScrobbleActionResult> => { 174 176 const { log = false, listenType = 'single'} = options; 175 - try { 176 - 177 - const listenPayload = playToListenPayload(play); 178 - if(listenType === 'playing_now') { 177 + const listenPayload = playToListenPayload(play); 178 + if(listenType === 'playing_now') { 179 179 delete listenPayload.listened_at; 180 180 } 181 - // https://tangled.org/rocksky.app/rocksky/blob/main/crates/scrobbler/src/listenbrainz/types.rs#L11 182 - // rocksky only uses duration_ms 183 - if(play.data.duration !== undefined && listenPayload.track_metadata.additional_info?.duration !== undefined) { 184 - delete listenPayload.track_metadata.additional_info.duration; 185 - listenPayload.track_metadata.additional_info.duration_ms = Math.round(play.data.duration) * 1000; 186 - } 187 - const submitPayload: SubmitPayload = {listen_type: listenType, payload: [listenPayload]}; 181 + // https://tangled.org/rocksky.app/rocksky/blob/main/crates/scrobbler/src/listenbrainz/types.rs#L11 182 + // rocksky only uses duration_ms 183 + if(play.data.duration !== undefined && listenPayload.track_metadata.additional_info?.duration !== undefined) { 184 + delete listenPayload.track_metadata.additional_info.duration; 185 + listenPayload.track_metadata.additional_info.duration_ms = Math.round(play.data.duration) * 1000; 186 + } 187 + const submitPayload: SubmitPayload = {listen_type: listenType, payload: [listenPayload]}; 188 + 189 + try { 188 190 if(log) { 189 191 this.logger.debug(`Submit Payload: ${JSON.stringify(submitPayload)}`); 190 192 } ··· 192 194 if(log) { 193 195 this.logger.debug(`Submit Response: ${resp.text}`) 194 196 } 195 - return resp.body as SubmitResponse; 197 + return {payload: submitPayload, response: resp.body as SubmitResponse}; 196 198 } catch (e) { 197 - throw e; 199 + throw new ScrobbleSubmitError(`Error occurred while making Rocksky API scrobble (${listenType}) request`, {cause: e, payload: submitPayload}); 198 200 } 199 201 } 200 202 ··· 208 210 } 209 211 210 212 export const rockskyScrobbleToPlay = (obj: RockskyScrobble): PlayObject => { 211 - const play: PlayObject = { 213 + const play: PlayObjectLifecycleless = { 212 214 data: { 213 215 track: obj.title, 214 216 artists: nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : [], ··· 222 224 } 223 225 }; 224 226 225 - return play; 227 + return baseFormatPlayObj(obj, play); 226 228 }
+17 -12
src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
··· 2 2 import { AbstractApiOptions } from "../../infrastructure/Atomic.js"; 3 3 import { ListRecord, ScrobbleRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; 4 4 import AbstractApiClient from "../AbstractApiClient.js"; 5 - import { Agent } from "@atproto/api"; 5 + import { Agent, ComAtprotoRepoCreateRecord } from "@atproto/api"; 6 6 import { MSCache } from "../../Cache.js"; 7 - import { BrainzMeta, PlayObject } from "../../../../core/Atomic.js"; 7 + import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult } from "../../../../core/Atomic.js"; 8 8 import { musicServiceToCononical } from "../ListenbrainzApiClient.js"; 9 9 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 10 10 import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; 11 11 import dayjs from "dayjs"; 12 12 import { getScrobbleTsSOCDateWithContext } from "../../../utils/TimeUtils.js"; 13 13 import { removeUndefinedKeys } from "../../../utils.js"; 14 + import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; 15 + import { ScrobbleSubmitError } from "../../errors/MSErrors.js"; 16 + import { UpstreamError } from "../../errors/UpstreamError.js"; 14 17 15 18 16 19 export abstract class AbstractBlueSkyApiClient extends AbstractApiClient { ··· 31 34 32 35 abstract restoreSession(): Promise<boolean>; 33 36 34 - async createScrobbleRecord(record: ScrobbleRecord): Promise<void> { 37 + async createScrobbleRecord(record: ScrobbleRecord): Promise<ScrobbleActionResult> { 38 + const input: ComAtprotoRepoCreateRecord.InputSchema = { 39 + repo: this.agent.sessionManager.did, 40 + collection: "fm.teal.alpha.feed.play", 41 + record 42 + }; 35 43 try { 36 - await this.agent.com.atproto.repo.createRecord({ 37 - repo: this.agent.sessionManager.did, 38 - collection: "fm.teal.alpha.feed.play", 39 - record 40 - }); 44 + const resp = await this.agent.com.atproto.repo.createRecord(input); 45 + return {payload: input, response: resp.data}; 41 46 } catch (e) { 42 - throw new Error(`Failed to create record`, { cause: e }); 47 + throw new ScrobbleSubmitError(`Failed to create record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined }); 43 48 } 44 49 } 45 50 ··· 52 57 }); 53 58 return response.data.records as unknown as ListRecord<ScrobbleRecord>[]; 54 59 } catch (e) { 55 - throw new Error(`Failed to create record`, { cause: e }); 60 + throw new UpstreamError(`Failed to list scrobble record`, { cause: e, response: 'response' in e ? e.response : undefined }); 56 61 } 57 62 } 58 63 } ··· 89 94 90 95 export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {}): PlayObject => { 91 96 92 - const play: PlayObject = { 97 + const play: PlayObjectLifecycleless = { 93 98 data: { 94 99 track: record.trackName, 95 100 artists: record.artists.filter(x => x.artistName !== undefined).map(x => x.artistName), ··· 120 125 play.data.meta = {brainz}; 121 126 } 122 127 123 - return play; 128 + return baseFormatPlayObj(record, play); 124 129 }; 125 130 export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal.alpha.feed.play\/(?<tid>.*))/); 126 131
+9 -7
src/backend/common/vendor/koito/KoitoApiClient.ts
··· 1 1 import dayjs from "dayjs"; 2 - import { PlayObject, URLData } from "../../../../core/Atomic.js"; 2 + import { PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../../core/Atomic.js"; 3 3 import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../../infrastructure/Atomic.js"; 4 4 import { KoitoData, ListenObjectResponse, ListensResponse } from "../../infrastructure/config/client/koito.js"; 5 5 import AbstractApiClient from "../AbstractApiClient.js"; ··· 10 10 import { SubmitPayload } from '../listenbrainz/interfaces.js'; 11 11 import { ListenType } from '../listenbrainz/interfaces.js'; 12 12 import { parseRegexSingleOrFail } from "../../../utils.js"; 13 + import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; 14 + import { ScrobbleSubmitError } from "../../errors/MSErrors.js"; 13 15 14 16 interface SubmitOptions { 15 17 log?: boolean ··· 154 156 } 155 157 } 156 158 157 - submitListen = async (play: PlayObject, options: SubmitOptions = {}) => { 159 + submitListen = async (play: PlayObject, options: SubmitOptions = {}): Promise<ScrobbleActionResult> => { 158 160 const { log = false, listenType = 'single' } = options; 161 + const listenPayload: SubmitPayload = { listen_type: listenType, payload: [playToListenPayload(play)] }; 159 162 try { 160 - const listenPayload: SubmitPayload = { listen_type: listenType, payload: [playToListenPayload(play)] }; 161 163 if (listenType === 'playing_now') { 162 164 delete listenPayload.payload[0].listened_at; 163 165 } ··· 172 174 if (log) { 173 175 this.logger.debug(`Submit Response: ${resp.text}`) 174 176 } 175 - return listenPayload; 177 + return {payload: listenPayload, response: resp.text}; 176 178 } catch (e) { 177 - throw e; 179 + throw new ScrobbleSubmitError(`Error occurred while making Koito API submit request (listen_type ${listenPayload.listen_type})`, {cause: e, payload: listenPayload, response: e.response, responseBody: e.response?.text}); 178 180 } 179 181 } 180 182 181 183 } 182 184 183 185 export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => { 184 - const play: PlayObject = { 186 + const play: PlayObjectLifecycleless = { 185 187 data: { 186 188 track: obj.track.title, 187 189 artists: (obj.track.artists ?? []).map(x => x.name), ··· 204 206 } 205 207 } 206 208 } 207 - return play; 209 + return baseFormatPlayObj(obj, play); 208 210 }
+31 -12
src/backend/common/vendor/maloja/MalojaApiClient.ts
··· 4 4 import AbstractApiClient from "../AbstractApiClient.js"; 5 5 import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; 6 6 import { MalojaData } from "../../infrastructure/config/client/maloja.js"; 7 - import { PlayObject, URLData } from "../../../../core/Atomic.js"; 7 + import { PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../../core/Atomic.js"; 8 8 import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js"; 9 9 import { isNodeNetworkException } from "../../errors/NodeErrors.js"; 10 10 import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; ··· 13 13 import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js"; 14 14 import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js'; 15 15 import { buildTrackString } from '../../../../core/StringUtils.js'; 16 + import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; 17 + import { ScrobbleSubmitError } from '../../errors/MSErrors.js'; 16 18 17 19 18 20 ··· 187 189 return list.map(formatPlayObj); 188 190 } 189 191 190 - scrobble = async (playObj: PlayObject): Promise<[(MalojaScrobbleData | undefined), MalojaScrobbleV3ResponseData, string?]> => { 192 + scrobble = async (playObj: PlayObject): Promise<ScrobbleActionResult> => { 191 193 192 194 const { 193 195 data: { ··· 213 215 .type('json') 214 216 .send(scrobbleData)); 215 217 216 - let scrobbleResponse: MalojaScrobbleData, 217 - scrobbledPlay: PlayObject; 218 - 218 + let scrobbleResponse: MalojaScrobbleData; 219 219 let responseBody: MalojaScrobbleV3ResponseData; 220 220 let warnStr: string; 221 + const msWarnings: string[] = []; 221 222 222 223 responseBody = response.body; 223 224 const { ··· 244 245 ...malojaAlbum, 245 246 } 246 247 } 248 + } else { 249 + msWarnings.push('Maloja did not return track data in scrobble response! Maybe it didn\'t scrobble correctly?'); 247 250 } 248 251 if (warnings.length > 0) { 249 252 for (const w of warnings) { 250 253 warnStr = builMalojadWarningString(w); 251 254 if (warnStr.includes('The submitted scrobble was not added')) { 252 - throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, { showStopper: false }); 255 + throw new ScrobbleSubmitError(`Maloja returned a warning but MS treating as error: ${warnStr}`, { showStopper: false, payload: scrobbleData, response, responseBody }); 253 256 } 254 - this.logger.warn(`Maloja Warning: ${warnStr}`); 257 + const wstring = `Maloja Warning: ${warnStr}`; 258 + msWarnings.push(wstring); 259 + this.logger.warn(wstring); 255 260 } 256 261 } 257 262 } else { 258 - throw new UpstreamError(buildMalojaErrorString(response.body), { showStopper: false }); 263 + throw new ScrobbleSubmitError(buildMalojaErrorString(response.body), { showStopper: false, payload: scrobbleData, response, responseBody }); 259 264 } 260 265 261 - return [scrobbleResponse, responseBody, warnStr] 266 + return {payload: scrobbleData, warnings: msWarnings.length > 0 ? msWarnings : undefined, response: responseBody, mergedScrobble: scrobbleResponse !== undefined ? formatPlayObj(scrobbleResponse, {url: this.url.normal}) : undefined}; 262 267 } catch (e) { 263 - this.logger.error(`Scrobble Error (${sType})`, { playInfo: buildTrackString(playObj), payload: scrobbleData }); 268 + let scrobbleError: ScrobbleSubmitError; 269 + if(e instanceof ScrobbleSubmitError) { 270 + scrobbleError = e; 271 + } else { 272 + scrobbleError = new ScrobbleSubmitError('Error occurred while submitting scrobble to Maloja', {cause: e, payload: scrobbleData, response: 'response' in e ? e.response : undefined}); 273 + } 274 + this.logger.error({ playInfo: buildTrackString(playObj), payload: scrobbleData }, `Scrobble Error (${sType})`); 264 275 const responseError = getMalojaResponseError(e); 265 276 if (responseError !== undefined) { 266 277 if (responseError.status < 500 && e instanceof UpstreamError) { 267 278 e.showStopper = false; 268 279 } 269 280 if (responseError.response?.text !== undefined) { 270 - this.logger.error('Raw Response:', { text: responseError.response?.text }); 281 + this.logger.error({ text: responseError.response?.text }, 'Raw Response'); 271 282 } 272 283 } 273 284 throw e; ··· 368 379 return [...acc, ...aStrings]; 369 380 }, []); 370 381 const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); 371 - return { 382 + const play: PlayObjectLifecycleless = { 372 383 data: removeUndefinedKeys({ 373 384 artists: [...new Set(artistStrings)] as string[], 374 385 track: title, ··· 384 395 } 385 396 } 386 397 } 398 + return baseFormatPlayObj(obj, play); 387 399 } 388 400 389 401 export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): MalojaScrobbleV3RequestData => { ··· 414 426 scrobbleData.duration = listenedFor; 415 427 } 416 428 429 + // leaving this undefined should result in compilation albums when 430 + // multiple artists are listed for the same album -- see Manual Scrobble page in Maloja UI 431 + // https://github.com/krateng/maloja/blob/master/maloja/web/static/js/manualscrobble.js#L101 432 + // https://github.com/krateng/maloja/blob/master/maloja/web/static/js/manualscrobble.js#L136 433 + // BUT this is not actually working! 417 434 if (albumArtists.length > 0) { 418 435 scrobbleData.albumartists = albumArtists; 419 436 } 437 + // see also https://github.com/krateng/maloja/issues/96#issuecomment-1490562761 438 + // https://github.com/FoxxMD/multi-scrobbler/issues/454#issuecomment-3806367420 420 439 421 440 return scrobbleData; 422 441 }
+10 -7
src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts
··· 1 1 import { Response } from 'superagent'; 2 - import { PlayObject, URLData } from "../../../../core/Atomic.js"; 2 + import { PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; 3 3 import { UpstreamError } from "../../errors/UpstreamError.js"; 4 4 import { AbstractApiOptions, FormatPlayObjectOptions, MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../../infrastructure/Atomic.js"; 5 5 import AbstractApiClient from "../AbstractApiClient.js"; ··· 18 18 import { getNodeNetworkException, hasNodeNetworkException, isNodeNetworkException } from '../../errors/NodeErrors.js'; 19 19 import { SimpleError } from '../../errors/MSErrors.js'; 20 20 import { resourceLimits } from 'worker_threads'; 21 + import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; 22 + import { IRecordingMSList } from '../../transforms/MusicbrainzTransformer.js'; 21 23 22 24 export interface SubmitResponse { 23 25 payload?: { ··· 193 195 } 194 196 } 195 197 196 - searchByRecording = async(play: PlayObject, options?: SearchOptions): Promise<IRecordingList | undefined> => { 198 + searchByRecording = async(play: PlayObject, options?: SearchOptions): Promise<IRecordingMSList | undefined> => { 197 199 198 200 const { 199 201 escapeCharacters = true, ··· 206 208 const cacheKey = `mb-recSearch-${hashObject({...playContentInvariantTransform(play), using})}`; 207 209 208 210 this.logger.debug(`Starting search`); 211 + let q = ''; 209 212 // https://github.com/Borewit/musicbrainz-api?tab=readme-ov-file#search-function 210 213 // https://wiki.musicbrainz.org/MusicBrainz_API/Search#Recording 211 214 // https://beta.musicbrainz.org/doc/MusicBrainz_API/Search ··· 247 250 query[k] = Array.isArray(v) ? v.map(removeNonWordCharacters) : removeNonWordCharacters(v); 248 251 } 249 252 } 250 - 251 - let q = ''; 252 253 253 254 if(freetext) { 254 255 ··· 328 329 throw new Error('results returned but no recordings list in response data, something handled incorrectly?'); 329 330 } 330 331 331 - return res; 332 + (res as IRecordingMSList).requestQuery = q; 333 + 334 + return res as IRecordingMSList; 332 335 } 333 336 334 337 testConnection = async () => { ··· 373 376 } 374 377 } 375 378 376 - const play: PlayObject = { 379 + const play: PlayObjectLifecycleless = { 377 380 data: { 378 381 track: data.title, 379 382 artists, ··· 398 401 } 399 402 } 400 403 401 - return play; 404 + return baseFormatPlayObj(data, play); 402 405 } 403 406 404 407
+44 -9
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 7 7 import { 8 8 DeadLetterScrobble, 9 9 PlayObject, 10 - QueuedScrobble, TA_DURING, 10 + PlayObjectLifecycleless, 11 + QueuedScrobble, ScrobbleActionResult, ScrobblePayload, ScrobbleResponse, TA_DURING, 11 12 TA_FUZZY, 12 13 TrackStringOptions 13 14 } from "../../core/Atomic.js"; 14 15 import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; 15 16 import AbstractComponent from "../common/AbstractComponent.js"; 16 - import { UpstreamError } from "../common/errors/UpstreamError.js"; 17 + import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 17 18 import { 18 19 ARTIST_WEIGHT, 19 20 Authenticatable, ··· 43 44 sleep, 44 45 sortByOldestPlayDate, 45 46 } from "../utils.js"; 46 - import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; 47 + import { findCauseByReference, messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; 47 48 import { 48 49 comparePlayTemporally, 49 50 hasAcceptableTemporalAccuracy, ··· 60 61 import { comparePlayArtistsNormalized, comparePlayTracksNormalized } from "../utils/PlayComparisonUtils.js"; 61 62 import { normalizeStr } from "../utils/StringUtils.js"; 62 63 import prom, { Counter, Gauge } from 'prom-client'; 64 + import { ScrobbleSubmitError } from "../common/errors/MSErrors.js"; 65 + import {serializeError} from 'serialize-error'; 63 66 64 67 type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>; 65 68 type NowPlayingQueue = Map<string, PlatformMappedPlays>; ··· 477 480 return [validTime, log] 478 481 } 479 482 480 - addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObject) => { 483 + addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObjectLifecycleless) => { 481 484 this.scrobbledPlayObjs.add({play: playObj, scrobble: scrobbledPlay}); 482 485 this.scrobbledCounter.labels(this.getPrometheusLabels()).inc(); 483 486 this.lastScrobbledPlayDate = playObj.data.playDate; ··· 488 491 this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ; 489 492 } 490 493 491 - getScrobbledPlays = () => this.scrobbledPlayObjs.data.map(x => x.scrobble) 494 + getScrobbledPlays = () => this.scrobbledPlayObjs.data.map(x => x.play) 492 495 493 496 findExistingSubmittedPlayObj = async (playObjPre: PlayObject): Promise<([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]])> => { 494 497 ··· 669 672 } 670 673 } 671 674 try { 672 - return this.doScrobble(playObj); 675 + const result = await this.doScrobble(playObj); 676 + playObj.meta.lifecycle.scrobble = { 677 + payload: result.payload, 678 + warnings: result.warnings, 679 + response: result.response, 680 + mergedScrobble: result.mergedScrobble 681 + } 682 + return playObj; 673 683 } finally { 674 684 this.lastScrobbleAttempt = dayjs(); 675 685 } 676 686 } 677 687 678 - protected abstract doScrobble(playObj: PlayObject): Promise<PlayObject> 688 + protected abstract doScrobble(playObj: PlayObject): Promise<ScrobbleActionResult & {play?: PlayObject}> 679 689 680 690 public abstract playToClientPayload(playObject: PlayObject): object 681 691 ··· 796 806 const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play); 797 807 if (timeFrameValid && !(await this.alreadyScrobbled(currQueuedPlay.play))) { 798 808 const transformedScrobble = await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare); 809 + transformedScrobble.meta.lifecycle.scrobble = this.playToClientPayload(transformedScrobble); 799 810 try { 800 811 const scrobbledPlay = await this.scrobble(transformedScrobble); 801 812 this.emitEvent('scrobble', {play: transformedScrobble}); 802 - this.addScrobbledTrack(transformedScrobble, scrobbledPlay); 813 + this.addScrobbledTrack(scrobbledPlay, scrobbledPlay.meta.lifecycle.scrobble.mergedScrobble ?? scrobbledPlay); 803 814 } catch (e) { 804 - if (e instanceof UpstreamError && e.showStopper === false) { 815 + currQueuedPlay.play.meta.lifecycle.scrobble = { 816 + }; 817 + 818 + const submitError = findCauseByReference(e, ScrobbleSubmitError); 819 + if(submitError !== undefined) { 820 + currQueuedPlay.play.meta.lifecycle.scrobble.payload = submitError.payload; 821 + currQueuedPlay.play.meta.lifecycle.scrobble.response = submitError.responseBody; 822 + currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(submitError); 823 + } else { 824 + currQueuedPlay.play.meta.lifecycle.scrobble.payload = this.playToClientPayload(transformedScrobble); 825 + currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(e); 826 + } 827 + 828 + if (hasUpstreamError(e, false)) { 805 829 this.addDeadLetterScrobble(currQueuedPlay, e); 806 830 this.logger.warn(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, {cause: e})); 807 831 } else { ··· 887 911 this.emitEvent('scrobble', {play: transformedScrobble}); 888 912 this.addScrobbledTrack(transformedScrobble, scrobbledPlay); 889 913 } catch (e) { 914 + 915 + const submitError = findCauseByReference(e, ScrobbleSubmitError); 916 + if(submitError !== undefined) { 917 + deadScrobble.play.meta.lifecycle.scrobble.payload = submitError.payload; 918 + deadScrobble.play.meta.lifecycle.scrobble.response = submitError.responseBody; 919 + deadScrobble.play.meta.lifecycle.scrobble.error = serializeError(submitError); 920 + } else { 921 + deadScrobble.play.meta.lifecycle.scrobble.payload = this.playToClientPayload(transformedScrobble); 922 + deadScrobble.play.meta.lifecycle.scrobble.error = serializeError(e); 923 + } 924 + 890 925 deadScrobble.retries++; 891 926 deadScrobble.error = messageWithCauses(e); 892 927 deadScrobble.lastRetry = dayjs();
+4 -4
src/backend/scrobblers/KoitoScrobbler.ts
··· 81 81 } = playObj; 82 82 83 83 try { 84 - await this.api.submitListen(playObj, { log: isDebugMode()}); 84 + const result = await this.api.submitListen(playObj, { log: isDebugMode()}); 85 85 86 86 if (newFromSource) { 87 87 this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); 88 88 } else { 89 89 this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`); 90 90 } 91 - return playObj; 91 + return result; 92 92 } catch (e) { 93 93 await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); 94 - throw new UpstreamError(`Error occurred while making Koito API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)}); 94 + throw e; 95 95 } 96 96 } 97 97 ··· 99 99 try { 100 100 await this.api.submitListen(data, { listenType: 'playing_now'}); 101 101 } catch (e) { 102 - throw new UpstreamError(`Error occurred while making Koito API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)}); 102 + throw e; 103 103 } 104 104 } 105 105 }
+4 -6
src/backend/scrobblers/LastfmScrobbler.ts
··· 9 9 import LastfmApiClient, { LastFMIgnoredScrobble, playToClientPayload, formatPlayObj, LASTFM_HOST, LASTFM_PATH } from "../common/vendor/LastfmApiClient.js"; 10 10 import { Notifiers } from "../notifier/Notifiers.js"; 11 11 import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; 12 + import { findCauseByReference } from "../utils/ErrorUtils.js"; 12 13 13 14 export default class LastfmScrobbler extends AbstractScrobbleClient { 14 15 ··· 95 96 } 96 97 return respPlay; 97 98 } catch (e) { 98 - if(e instanceof LastFMIgnoredScrobble) { 99 + const ignored = findCauseByReference(e, LastFMIgnoredScrobble); 100 + if(ignored !== undefined) { 99 101 await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Ignored`, message: `Failed to scrobble => ${buildTrackString(playObj)} | ${e.message}`, priority: 'warn'}); 100 102 } else { 101 103 await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); 102 104 } 103 105 this.logger.error({playInfo: buildTrackString(playObj), payload: playToClientPayload(playObj)}, `Scrobble Error (${sType})`); 104 - if(!(e instanceof UpstreamError)) { 105 - throw new UpstreamError(`Error received from ${this.upstreamType} API`, {cause: e, showStopper: true}); 106 - } else { 107 - throw e; 108 - } 106 + throw e; 109 107 } 110 108 } 111 109
+6 -6
src/backend/scrobblers/ListenbrainzScrobbler.ts
··· 3 3 import { PlayObject } from "../../core/Atomic.js"; 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 - import { UpstreamError } from "../common/errors/UpstreamError.js"; 6 + import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 7 7 import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 8 8 import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js"; 9 - import { ListenbrainzApiClient, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; 9 + import { ListenbrainzApiClient, playToListenPayload, playToSubmitPayload } from "../common/vendor/ListenbrainzApiClient.js"; 10 10 import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; 11 11 import { Notifiers } from "../notifier/Notifiers.js"; 12 12 ··· 84 84 } = playObj; 85 85 86 86 try { 87 - await this.api.submitListen(playObj, { log: isDebugMode()}); 87 + const result = await this.api.submitListen(playObj, { log: isDebugMode()}); 88 88 89 89 if (newFromSource) { 90 90 this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); 91 91 } else { 92 92 this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`); 93 93 } 94 - return playObj; 94 + return result; 95 95 } catch (e) { 96 96 await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); 97 - throw new UpstreamError(`Error occurred while making Listenbrainz API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)}); 97 + throw e; 98 98 } 99 99 } 100 100 ··· 103 103 try { 104 104 await this.api.submitListen(data, { listenType: 'playing_now'}); 105 105 } catch (e) { 106 - throw new UpstreamError(`Error occurred while making Listenbrainz API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)}); 106 + throw e; 107 107 } 108 108 } 109 109 }
+5 -17
src/backend/scrobblers/MalojaScrobbler.ts
··· 12 12 import { Notifiers } from "../notifier/Notifiers.js"; 13 13 import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 14 14 import { MalojaApiClient, formatPlayObj as formatMalojaScrobbleToPlay, playToScrobblePayload } from "../common/vendor/maloja/MalojaApiClient.js"; 15 + import { ScrobbleSubmitError } from "../common/errors/MSErrors.js"; 15 16 16 17 const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"]; 17 18 ··· 98 99 99 100 const scrobbleData = playToScrobblePayload(playObj); 100 101 101 - let scrobbledPlay: PlayObject; 102 - 103 102 try { 104 - const [scrobbleResp, respBody, warnStr] = await this.api.scrobble(playObj); 105 - 106 - let warning = ''; 107 - if (scrobbleResp === undefined) { 108 - warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`; 109 - scrobbledPlay = playObj; 110 - } else { 111 - scrobbledPlay = this.formatPlayObj(scrobbleResp) 112 - } 103 + const result = await this.api.scrobble(playObj); 113 104 const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`; 114 - if (warning !== '') { 115 - this.logger.warn(`${scrobbleInfo} | ${warning}`); 116 - this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(respBody))}`); 105 + if (result.warnings?.length > 0) { 106 + this.logger.warn(`${scrobbleInfo} | ${result.warnings.join(' | ')}`); 117 107 } else { 118 108 this.logger.info(scrobbleInfo); 119 109 } 120 - return scrobbledPlay; 110 + return result; 121 111 } catch (e) { 122 112 await this.notifier.notify({ title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error' }); 123 113 throw e; 124 - } finally { 125 - this.logger.debug('Raw Payload:', scrobbleData); 126 114 } 127 115 } 128 116 }
+9 -8
src/backend/scrobblers/RockskyScrobbler.ts
··· 3 3 import { PlayObject } from "../../core/Atomic.js"; 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 - import { UpstreamError } from "../common/errors/UpstreamError.js"; 6 + import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 7 7 import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 8 8 import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js"; 9 9 import { ListenbrainzApiClient, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; ··· 12 12 13 13 import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 14 14 import { isDebugMode } from "../utils.js"; 15 - import { RockSkyApiClient } from "../common/vendor/RockSkyApiClient.js"; 15 + import { RockSkyApiClient, SubmitResponse } from "../common/vendor/RockSkyApiClient.js"; 16 16 import { RockSkyClientConfig } from "../common/infrastructure/config/client/rocksky.js"; 17 + import { ScrobbleSubmitError } from "../common/errors/MSErrors.js"; 17 18 18 19 export default class RockskyScrobbler extends AbstractScrobbleClient { 19 20 ··· 84 85 } = playObj; 85 86 86 87 try { 87 - const resp = await this.api.submitListen(playObj, { log: isDebugMode()}); 88 + const result = await this.api.submitListen(playObj, { log: isDebugMode()}); 88 89 89 - if((resp.payload?.ignored_listens ?? 0) > 0) { 90 - throw new UpstreamError('Scrobble was successfully submitted but Rocksky ignored it', {showStopper: false}); 90 + if(((result.response as SubmitResponse).payload?.ignored_listens ?? 0) > 0) { 91 + throw new ScrobbleSubmitError('Scrobble was successfully submitted but Rocksky ignored it', {showStopper: false, responseBody: result.response, payload: result.payload}); 91 92 } 92 93 93 94 if (newFromSource) { ··· 95 96 } else { 96 97 this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`); 97 98 } 98 - return playObj; 99 + return result; 99 100 } catch (e) { 100 101 await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); 101 - throw new UpstreamError(`Error occurred while making Rocksky API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)}); 102 + throw e; 102 103 } 103 104 } 104 105 ··· 106 107 try { 107 108 await this.api.submitListen(data, { listenType: 'playing_now'}); 108 109 } catch (e) { 109 - throw new UpstreamError(`Error occurred while making Rocksky API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)}); 110 + throw e; 110 111 } 111 112 } 112 113 }
+2 -1
src/backend/scrobblers/ScrobbleClients.ts
··· 20 20 import { CommonClientOptions } from '../common/infrastructure/config/client/index.js'; 21 21 import { ExternalMetadataTerm, PlayTransformHooks } from '../common/infrastructure/Transform.js'; 22 22 import { LibrefmClientConfig } from '../common/infrastructure/config/client/librefm.js'; 23 + import clone from 'clone'; 23 24 24 25 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 25 26 ··· 487 488 continue; 488 489 } 489 490 for (const playObj of playObjs) { 490 - await client.queueScrobble(playObj, scrobbleFrom); 491 + await client.queueScrobble(clone(playObj), scrobbleFrom); 491 492 } 492 493 } 493 494 }
+4 -4
src/backend/scrobblers/TealfmScrobbler.ts
··· 3 3 import { PlayObject } from "../../core/Atomic.js"; 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 - import { UpstreamError } from "../common/errors/UpstreamError.js"; 6 + import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 7 7 import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 8 8 import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; 9 9 import { Notifiers } from "../notifier/Notifiers.js"; ··· 113 113 } = playObj; 114 114 115 115 try { 116 - await this.client.createScrobbleRecord(playToRecord(playObj)) 116 + const res = await this.client.createScrobbleRecord(playToRecord(playObj)) 117 117 if (newFromSource) { 118 118 this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); 119 119 } else { 120 120 this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`); 121 121 } 122 - return playObj; 122 + return res; 123 123 } catch (e) { 124 124 await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); 125 - throw new UpstreamError(`Error occurred while making Teal API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)}); 125 + throw e; 126 126 } 127 127 } 128 128 }
+4 -2
src/backend/sources/AzuracastSource.ts
··· 4 4 import { EventEmitter } from "events"; 5 5 import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket' 6 6 import pEvent from 'p-event'; 7 - import { PlayObject, URLData } from "../../core/Atomic.js"; 7 + import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; 8 8 import { UpstreamError } from "../common/errors/UpstreamError.js"; 9 9 import { 10 10 FormatPlayObjectOptions, ··· 16 16 } from "../common/infrastructure/Atomic.js"; 17 17 import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js"; 18 18 import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js"; 19 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 19 20 20 21 21 22 export class AzuracastSource extends MemorySource { ··· 247 248 248 249 const track: string = title ?? text; 249 250 250 - return { 251 + const play: PlayObjectLifecycleless = { 251 252 data: { 252 253 artists: artist !== undefined && artist !== '' ? [artist] : [], 253 254 album: album !== '' ? album : undefined, ··· 260 261 mediaPlayerName: 'Azuracast' 261 262 } 262 263 } 264 + return baseFormatPlayObj(obj, play); 263 265 } 264 266 265 267 const getMessageData = <T>(e: any): T => {
+5 -3
src/backend/sources/ChromecastSource.ts
··· 5 5 import dayjs from "dayjs"; 6 6 import { EventEmitter } from "events"; 7 7 import e from "express"; 8 - import { PlayObject } from "../../core/Atomic.js"; 8 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 9 9 import { buildTrackString } from "../../core/StringUtils.js"; 10 10 import { NETWORK_ERROR_FAILURE_CODES } from "../common/errors/NodeErrors.js"; 11 11 import { ··· 35 35 import { discoveryAvahi, discoveryNative } from "../utils/MDNSUtils.js"; 36 36 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 37 37 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 38 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 38 39 39 40 interface ChromecastDeviceInfo { 40 41 mdns: MdnsDeviceInfo ··· 716 717 trackProgressPosition = currentTime; 717 718 } 718 719 719 - return { 720 + const play: PlayObjectLifecycleless = { 720 721 data: { 721 722 track, 722 723 album, ··· 731 732 deviceId, 732 733 source 733 734 } 734 - } 735 + }; 736 + return baseFormatPlayObj(obj, play); 735 737 } 736 738 }
+4 -3
src/backend/sources/DeezerInternalSource.ts
··· 1 1 import dayjs from "dayjs"; 2 2 import EventEmitter from "events"; 3 3 import request, { Request, Response, SuperAgent } from 'superagent'; 4 - import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js"; 4 + import { PlayObject, PlayObjectLifecycleless, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js"; 5 5 import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 6 6 import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; 7 7 import { TRANSFORM_HOOK } from "../common/infrastructure/Transform.js"; ··· 13 13 import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js"; 14 14 import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js"; 15 15 import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js"; 16 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 16 17 17 18 interface DeezerHistoryResponse { 18 19 errors: [] ··· 68 69 69 70 static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject { 70 71 const {newFromSource = false} = options; 71 - const play: PlayObject = { 72 + const play: PlayObjectLifecycleless = { 72 73 data: { 73 74 artists: [obj.ART_NAME], 74 75 album: obj.ALB_TITLE, ··· 91 92 album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg` 92 93 } 93 94 } 94 - return play; 95 + return baseFormatPlayObj(obj, play); 95 96 } 96 97 97 98 protected async doBuildInitData(): Promise<true | string | undefined> {
+4 -2
src/backend/sources/DeezerSource.ts
··· 3 3 import passport from "passport"; 4 4 import { Strategy as DeezerStrategy } from 'passport-deezer'; 5 5 import request from 'superagent'; 6 - import { PlayObject } from "../../core/Atomic.js"; 6 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 7 7 import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 8 8 import { DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; 9 9 import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js"; 10 10 import { readJson } from '../utils/DataUtils.js'; 11 11 import { joinedUrl } from "../utils/NetworkUtils.js"; 12 12 import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; 13 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 13 14 14 15 export default class DeezerSource extends AbstractSource { 15 16 workingCredsPath; ··· 72 73 title: albumName, 73 74 } = {}, 74 75 } = obj; 75 - return { 76 + const play: PlayObjectLifecycleless = { 76 77 data: { 77 78 artists: [artistName], 78 79 album: albumName, ··· 90 91 } 91 92 } 92 93 } 94 + return baseFormatPlayObj(obj, play); 93 95 } 94 96 95 97 protected async doBuildInitData(): Promise<true | string | undefined> {
+4 -2
src/backend/sources/IcecastSource.ts
··· 1 1 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 2 2 import { EventEmitter } from "events"; 3 - import { PlayObject, URLData } from "../../core/Atomic.js"; 3 + import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; 4 4 import { 5 5 FormatPlayObjectOptions, 6 6 InternalConfig, ··· 14 14 import IcecastMetadataStats from "icecast-metadata-stats"; 15 15 import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.js"; 16 16 import { isDebugMode, sleep } from "../utils.js"; 17 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 17 18 18 19 19 20 export class IcecastSource extends MemorySource { ··· 207 208 } 208 209 } 209 210 210 - return { 211 + const play: PlayObjectLifecycleless = { 211 212 data: { 212 213 track, 213 214 artists ··· 217 218 mediaPlayerName: 'Icecast' 218 219 } 219 220 } 221 + return baseFormatPlayObj(obj, play); 220 222 }
+4 -2
src/backend/sources/JRiverSource.ts
··· 2 2 import { EventEmitter } from "events"; 3 3 import normalizeUrl from 'normalize-url'; 4 4 import { URL } from "url"; 5 - import { PlayObject } from "../../core/Atomic.js"; 5 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 6 6 import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 7 7 import { JRiverSourceConfig } from "../common/infrastructure/config/source/jriver.js"; 8 8 import { Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRiverApiClient.js"; 9 9 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 10 10 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 11 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 11 12 12 13 export class JRiverSource extends MemoryPositionalSource { 13 14 declare config: JRiverSourceConfig; ··· 99 100 const album = Album === null || Album === '' ? undefined : Album; 100 101 const length = Number.parseInt(DurationMS.toString()) / 1000; 101 102 102 - return { 103 + const play: PlayObjectLifecycleless = { 103 104 data: { 104 105 track: Name, 105 106 album: album, ··· 117 118 deviceId: `Zone${ZoneID}${ZoneName !== undefined ? `-${ZoneName}` : ''}`, 118 119 } 119 120 } 121 + return baseFormatPlayObj(obj, play); 120 122 } 121 123 122 124 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
+4 -3
src/backend/sources/JellyfinApiSource.ts
··· 45 45 import { nanoid } from "nanoid"; 46 46 import pEvent from "p-event"; 47 47 import { Simulate } from "react-dom/test-utils"; 48 - import { BrainzMeta, PlayObject } from "../../core/Atomic.js"; 48 + import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 49 49 import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 50 50 import { 51 51 FormatPlayObjectOptions, ··· 60 60 import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js"; 61 61 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 62 62 import { FixedSizeList } from "fixed-size-list"; 63 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 63 64 64 65 const shortDeviceId = truncateStringToLength(10, ''); 65 66 ··· 438 439 meta.albumArtist = [ProviderIds.MusicBrainzAlbumArtist]; 439 440 } 440 441 441 - const play: PlayObject = { 442 + const play: PlayObjectLifecycleless = { 442 443 data: { 443 444 artists: Artists, 444 445 album: Album, ··· 457 458 if(Object.keys(meta).length > 0) { 458 459 play.data.meta = { brainz: meta }; 459 460 } 460 - return play; 461 + return baseFormatPlayObj(obj,play); 461 462 } 462 463 463 464 getRecentlyPlayed = async (options = {}) => {
+4 -2
src/backend/sources/MPDSource.ts
··· 2 2 import { EventEmitter } from "events"; 3 3 import mpdapiNS, { MPDApi } from 'mpd-api'; 4 4 import mpd2 from 'mpd2'; 5 - import { BrainzMeta, PlayObject } from "../../core/Atomic.js"; 5 + import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 6 6 import { 7 7 FormatPlayObjectOptions, 8 8 InternalConfig, ··· 20 20 import { isPortReachable } from "../utils/NetworkUtils.js"; 21 21 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 22 22 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 23 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 23 24 24 25 const mpdClient = mpdapiNS.default; 25 26 ··· 187 188 brainz.artist = [musicbrainz_artistid]; 188 189 } 189 190 190 - return { 191 + const play: PlayObjectLifecycleless = { 191 192 data: { 192 193 artists: artists, 193 194 albumArtists, ··· 201 202 mediaPlayerName: 'mpd' 202 203 } 203 204 } 205 + return baseFormatPlayObj({...obj, trackProgressPosition: options.trackProgressPosition}, play); 204 206 } 205 207 206 208 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
+4 -2
src/backend/sources/MPRISSource.ts
··· 2 2 import dayjs from "dayjs"; 3 3 import { DBusInterface, MessageBus, sessionBus, Connection, ConnectOpts } from 'dbus-ts'; 4 4 import EventEmitter from "events"; 5 - import { PlayObject } from "../../core/Atomic.js"; 5 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 6 6 import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 7 7 import { 8 8 MPRIS_IFACE, ··· 20 20 import { Readable, Writable } from 'stream'; 21 21 import net from 'net'; 22 22 import pEvent from 'p-event'; 23 + import { baseFormatPlayObj } from '../utils/PlayTransformUtils.js'; 23 24 24 25 25 26 export class MPRISSource extends MemorySource { ··· 71 72 actualAlbumArtists = albumArtist; 72 73 } 73 74 74 - return { 75 + const play: PlayObjectLifecycleless = { 75 76 data: { 76 77 track: title, 77 78 album, ··· 91 92 deviceId: name, 92 93 } 93 94 } 95 + return baseFormatPlayObj(obj, play); 94 96 } 95 97 96 98 protected async doCheckConnection(): Promise<true | string | undefined> {
+4 -2
src/backend/sources/MopidySource.ts
··· 5 5 import normalizeUrl from 'normalize-url'; 6 6 import pEvent from 'p-event'; 7 7 import { URL } from "url"; 8 - import { PlayObject } from "../../core/Atomic.js"; 8 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 9 9 import { buildTrackString } from "../../core/StringUtils.js"; 10 10 import { 11 11 FormatPlayObjectOptions, ··· 16 16 import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js"; 17 17 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 18 18 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 19 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 19 20 20 21 export class MopidySource extends MemoryPositionalSource { 21 22 declare config: MopidySourceConfig; ··· 159 160 artists = performers; 160 161 } 161 162 162 - return { 163 + const play: PlayObjectLifecycleless = { 163 164 data: { 164 165 track: name, 165 166 album: albumName, ··· 177 178 //deviceId: name, 178 179 } 179 180 } 181 + return baseFormatPlayObj({...obj, trackProgressPosition}, play); 180 182 } 181 183 182 184 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
+4 -2
src/backend/sources/MusicCastSource.ts
··· 1 1 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 2 2 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 3 3 import { EventEmitter } from "events"; 4 - import { PlayObject, URLData } from "../../core/Atomic.js"; 4 + import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; 5 5 import { 6 6 FormatPlayObjectOptions, 7 7 InternalConfig, ··· 11 11 import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; 12 12 import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; 13 13 import request, { Request, Response } from 'superagent'; 14 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 14 15 15 16 16 17 export class MusicCastSource extends MemoryPositionalSource { ··· 146 147 playback 147 148 } = obj; 148 149 149 - return { 150 + const play: PlayObjectLifecycleless = { 150 151 data: { 151 152 artists: artist !== undefined && artist !== '' ? [artist] : [], 152 153 album: album !== '' ? album : undefined, ··· 161 162 mediaPlayerVersion: options.version 162 163 } 163 164 } 165 + return baseFormatPlayObj(obj, play); 164 166 }
+4 -2
src/backend/sources/MusikcubeSource.ts
··· 5 5 import normalizeUrl from 'normalize-url'; 6 6 import pEvent from 'p-event'; 7 7 import { URL } from "url"; 8 - import { PlayObject, URLData } from "../../core/Atomic.js"; 8 + import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; 9 9 import { UpstreamError } from "../common/errors/UpstreamError.js"; 10 10 import { 11 11 FormatPlayObjectOptions, ··· 24 24 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 25 25 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 26 26 import { normalizeWSAddress } from "../utils/NetworkUtils.js"; 27 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 27 28 28 29 const CLIENT_STATE = { 29 30 0: 'connecting', ··· 188 189 if(album_artist !== undefined && album_artist !== artist) { 189 190 albumArtists.push(album_artist); 190 191 } 191 - return { 192 + const play: PlayObjectLifecycleless = { 192 193 data: { 193 194 artists: artists, 194 195 albumArtists, ··· 204 205 mediaPlayerVersion: this.version 205 206 } 206 207 } 208 + return baseFormatPlayObj(obj, play); 207 209 } 208 210 209 211 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
+16 -4
src/backend/sources/PlexApiSource.ts
··· 1 1 import EventEmitter from "events"; 2 - import { PlayObject, URLData } from "../../core/Atomic.js"; 2 + import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; 3 3 import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 4 4 import { 5 5 asPlayerStateDataMaybePlay, 6 6 FormatPlayObjectOptions, 7 7 InternalConfig, 8 + MBID_VARIOUS_ARTISTS, 8 9 NO_USER, 9 10 PlayerStateData, 10 11 PlayerStateDataMaybePlay, ··· 28 29 import { SDKValidationError } from '@lukehagar/plexjs/sdk/models/errors/sdkvalidationerror.js'; 29 30 import { Keyv } from 'cacheable'; 30 31 import { initMemoryCache } from "../common/Cache.js"; 32 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 33 + import clone from 'clone'; 31 34 32 35 const shortDeviceId = truncateStringToLength(10, ''); 33 36 ··· 368 371 realArtists.push(artist); 369 372 } 370 373 371 - return { 374 + const play: PlayObjectLifecycleless = { 372 375 data: { 373 376 artists: realArtists, 374 377 albumArtists, ··· 391 394 trackProgressPosition: viewOffset / 1000, 392 395 } 393 396 } 397 + return baseFormatPlayObj(obj, play); 394 398 } 395 399 396 400 getRecentlyPlayed = async (options = {}) => { ··· 421 425 track: trackMbId, 422 426 album: albumMbId, 423 427 // Plex doesn't track MBIDs for track artists, so we use the 424 - // album artist MBID instead. 425 - artist: albumArtistMbId !== undefined 428 + // album artist MBID instead BUT ONLY if 429 + // * artists aren't populated 430 + // * or artists == albumArtists 431 + // AND explicitly *do not* allow Various Artists MBID for artists 432 + // -- 433 + // otherwise we might accidentally set "Various Artists" like MBIDs as actual artist 434 + artist: albumArtistMbId !== undefined && albumArtistMbId !== MBID_VARIOUS_ARTISTS && (sessionData[0].play.data.artists.length === 0 || sessionData[0].play.data.artists.every(y => (sessionData[0].play.data.albumArtists ?? []).includes(y))) 426 435 ? [...new Set([...(prevBrainzMeta.artist ?? []), albumArtistMbId])] 427 436 : prevBrainzMeta.artist, 428 437 albumArtist: albumArtistMbId !== undefined 429 438 ? [...new Set([...(prevBrainzMeta.albumArtist ?? []), albumArtistMbId])] 430 439 : prevBrainzMeta.albumArtist, 431 440 }; 441 + 442 + // need to add this to original object since lifecycle has already been set in sessionToPlayerState 443 + sessionData[0].play.meta.lifecycle.original = clone(sessionData[0].play); 432 444 433 445 validSessions.push(sessionData[0]); 434 446 } else if(this.logFilterFailure !== false) {
+4 -2
src/backend/sources/SonosSource.ts
··· 1 1 import { EventEmitter } from "events"; 2 - import { PlayObject } from "../../core/Atomic.js"; 2 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 3 3 import { 4 4 FormatPlayObjectOptions, 5 5 InternalConfig, ··· 23 23 import { isDebugMode, playObjDataMatch, sleep } from "../utils.js"; 24 24 import { playContentInvariantTransform } from "../utils/PlayComparisonUtils.js"; 25 25 import dayjs, { Dayjs } from "dayjs"; 26 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 26 27 27 28 export interface DeviceState { 28 29 device: SonosDevice ··· 374 375 titleStr = Title; 375 376 } 376 377 377 - return { 378 + const play: PlayObjectLifecycleless = { 378 379 data: { 379 380 track: titleStr, 380 381 album: Album, ··· 392 393 source: 'Sonos' 393 394 } 394 395 } 396 + return baseFormatPlayObj({...obj, device: options.device}, play); 395 397 } 396 398 397 399 export const getInvariantDeviceData = (data: DeviceState): SimpleDeviceState => {
+17 -4
src/backend/sources/SpotifySource.ts
··· 2 2 import EventEmitter from "events"; 3 3 import SpotifyWebApi from "spotify-web-api-node"; 4 4 import request from 'superagent'; 5 - import { BrainzMeta, PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; 5 + import { BrainzMeta, PlayObject, PlayObjectLifecycleless, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; 6 6 import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 7 7 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 8 8 import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; ··· 34 34 import TrackObjectFull = SpotifyApi.TrackObjectFull; 35 35 import UserDevice = SpotifyApi.UserDevice; 36 36 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 37 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 37 38 38 39 const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position']; 39 40 const state = 'random'; ··· 110 111 external_ids: { 111 112 isrc 112 113 }, 113 - track_number 114 + track_number, 114 115 } = track; 115 116 117 + // we don't use available markets for anything and it can be 100+ strings 118 + // so delete for debugging sake 119 + delete obj.track.available_markets; 120 + if(obj.track.album !== undefined) { 121 + delete obj.track.album?.available_markets; 122 + } 123 + 116 124 scrobbleTsSOC = SCROBBLE_TS_SOC_END; 117 125 played_at = dayjs(pa); 118 126 playDateCompleted = played_at; ··· 151 159 track_number 152 160 } = item as TrackObjectFull; 153 161 162 + delete (obj.item as TrackObjectFull).available_markets; 163 + if((obj.item as TrackObjectFull).album !== undefined) { 164 + delete (obj.item as TrackObjectFull).album.available_markets; 165 + } 166 + 154 167 scrobbleTsSOC = SCROBBLE_TS_SOC_START; 155 168 played_at = dayjs(timestamp); 156 169 artists = art; ··· 190 203 } 191 204 } 192 205 193 - const play: PlayObject = { 206 + const play: PlayObjectLifecycleless = { 194 207 data: { 195 208 artists: artists.map(x => x.name), 196 209 albumArtists: actualAlbumArtists.map(x => x.name), ··· 236 249 play.meta.art = {album: imageData.url}; 237 250 } 238 251 239 - return play; 252 + return baseFormatPlayObj(obj, play); 240 253 } 241 254 242 255 buildSpotifyApi = async () => {
+4 -2
src/backend/sources/SubsonicSource.ts
··· 3 3 import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; 4 4 import EventEmitter from "events"; 5 5 import request, { Request } from 'superagent'; 6 - import { PlayObject } from "../../core/Atomic.js"; 6 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 7 7 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 8 8 import { UpstreamError } from "../common/errors/UpstreamError.js"; 9 9 import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; ··· 16 16 import { SubsonicPlayerState } from './PlayerState/SubsonicPlayerState.js'; 17 17 import { PlayerStateOptions } from './PlayerState/AbstractPlayerState.js'; 18 18 import { Logger } from '@foxxmd/logging'; 19 + import { baseFormatPlayObj } from '../utils/PlayTransformUtils.js'; 19 20 20 21 dayjs.extend(isSameOrAfter); 21 22 ··· 74 75 username, 75 76 } = obj; 76 77 77 - return { 78 + const play: PlayObjectLifecycleless = { 78 79 data: { 79 80 artists: [artist], 80 81 album, ··· 94 95 mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version 95 96 } 96 97 } 98 + return baseFormatPlayObj(obj, play); 97 99 } 98 100 99 101 callApi = async <T extends SubsonicResponseCommon = SubsonicResponseCommon>(req: Request, retries = 0): Promise<T> => {
+4 -2
src/backend/sources/VLCSource.ts
··· 2 2 import { EventEmitter } from "events"; 3 3 import * as VLC from "vlc-client" 4 4 import { VlcMeta, VlcStatus } from "vlc-client/dist/Types.js"; 5 - import { PlayObject } from "../../core/Atomic.js"; 5 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 6 6 import { 7 7 FormatPlayObjectOptions, 8 8 InternalConfig, ··· 17 17 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 18 18 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 19 19 import { isDebugMode } from "../utils.js"; 20 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 20 21 21 22 const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = { 22 23 'playing': REPORTED_PLAYER_STATUSES.playing, ··· 214 215 state 215 216 } = vlcState || {}; 216 217 217 - return { 218 + const play: PlayObjectLifecycleless = { 218 219 data: { 219 220 artists: artists, 220 221 albumArtists, ··· 229 230 mediaPlayerVersion: this.vlcVersion 230 231 } 231 232 } 233 + return baseFormatPlayObj({...obj, vlcState}, play); 232 234 } 233 235 234 236 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
+4 -2
src/backend/sources/WebScrobblerSource.ts
··· 1 1 import dayjs from "dayjs"; 2 2 import EventEmitter from "events"; 3 - import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 3 + import { PlayObject, PlayObjectLifecycleless, SOURCE_SOT } from "../../core/Atomic.js"; 4 4 import { 5 5 FormatPlayObjectOptions, 6 6 InternalConfig, ··· 22 22 import { Logger } from "@foxxmd/logging"; 23 23 import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 24 24 import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; 25 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 25 26 26 27 export class WebScrobblerSource extends MemorySource { 27 28 ··· 122 123 const albumArtist = processed.albumArtist ?? parsed.albumArtist; 123 124 const duration = parsed.duration ?? processed.duration; 124 125 125 - return { 126 + const play: PlayObjectLifecycleless = { 126 127 data: { 127 128 track, 128 129 artists: [artist], ··· 149 150 nowPlaying: options.nowPlaying ?? false 150 151 } 151 152 } 153 + return baseFormatPlayObj(obj, play); 152 154 } 153 155 154 156 getRecentlyPlayed = async (options = {}) => this.getFlatRecentlyDiscoveredPlays()
+4 -2
src/backend/sources/YTMusicSource.ts
··· 1 1 import dayjs, { Dayjs } from "dayjs"; 2 2 import EventEmitter from "events"; 3 - import { PlayObject } from "../../core/Atomic.js"; 3 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 4 4 import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 5 5 import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; 6 6 import { Innertube, UniversalCache, Parser, YTNodes, ApiResponse, IBrowseResponse, Log, SessionOptions } from 'youtubei.js'; ··· 21 21 import { joinedUrl } from "../utils/NetworkUtils.js"; 22 22 import { todayAwareFormat } from "../utils/TimeUtils.js"; 23 23 import { parseArrayFromMaybeString, parseArtistCredits, parseCredits } from "../utils/StringUtils.js"; 24 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 24 25 25 26 export interface HistoryIngressResult { 26 27 plays: PlayObject[], ··· 411 412 const durObj = dayjs.duration(dur.seconds, 's') 412 413 duration = durObj.asSeconds(); 413 414 } 414 - return { 415 + const play: PlayObjectLifecycleless = { 415 416 data: { 416 417 artists, 417 418 albumArtists, ··· 429 430 comment: shelf 430 431 } 431 432 } 433 + return baseFormatPlayObj(obj, play); 432 434 } 433 435 434 436 recentlyPlayedTrackIsValid = (playObj: PlayObject) => playObj.meta.newFromSource
+4 -1
src/backend/tests/jellyfin/jellyfin.test.ts
··· 16 16 // @ts-expect-error weird typings? 17 17 import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js"; 18 18 import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; 19 + import { MarkOptional } from "ts-essentials"; 20 + import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; 19 21 20 22 const dataAsFixture = (data: any): TestFixture => { 21 23 return data as TestFixture; ··· 41 43 platformId: ['1234', 'MyUser'], 42 44 play: generatePlay({}, {mediaType: 'Audio', user: 'MyUser', deviceId: '1234'}) 43 45 } 44 - const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { 46 + const playWithMeta = (meta: MarkOptional<PlayMeta, 'lifecycle'>): PlayerStateDataMaybePlay => { 45 47 const {user, deviceId} = meta; 46 48 const platformId = validPlayerState.platformId; 47 49 return { ··· 50 52 play: { 51 53 ...validPlayerState.play, 52 54 meta: { 55 + lifecycle: defaultLifecycle(), 53 56 ...validPlayerState.play?.meta, 54 57 ...meta 55 58 }
+4 -1
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 23 23 import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTrackName.json' with { type: "json" }; 24 24 import veryWrong from './incorrectlyMapped/veryWrong.json' with { type: "json" }; 25 25 import { generatePlay } from "../utils/PlayTestUtils.js"; 26 + import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; 26 27 27 28 interface LZTestFixture { 28 29 data: ListenResponse ··· 141 142 } 142 143 } 143 144 }, 144 - meta: {} 145 + meta: { 146 + lifecycle: defaultLifecycle() 147 + } 145 148 } 146 149 try { 147 150 await client.submitListen(play);
+38 -12
src/backend/tests/musicbrainz/musicbrainz.test.ts
··· 14 14 import { http, HttpResponse, delay } from "msw"; 15 15 import { generatePlay, withBrainz } from '../utils/PlayTestUtils.js'; 16 16 import { intersect, missingMbidTypes } from '../../utils.js'; 17 + import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; 17 18 18 19 const envPath = path.join(projectDir, '.env'); 19 20 dotenv.config({ path: envPath }); ··· 63 64 artists: ["Khruangbin"], 64 65 album: "The Universe Smiles Upon You ii" 65 66 }, 66 - meta: {} 67 + meta: { 68 + lifecycle: defaultLifecycle() 69 + } 67 70 } 68 71 await mbTransformer.tryInitialize(); 69 72 ··· 93 96 url: { 94 97 web: "https://www.last.fm/music/Kanon+Oguni/_/Cyber+Space+(CrossWorlds+Remix):+Final+Lap+-+No+Chants", 95 98 }, 99 + lifecycle: defaultLifecycle() 96 100 } 97 101 }; 98 102 await mbTransformer.tryInitialize(); ··· 121 125 } 122 126 } 123 127 }, 124 - meta: {} 128 + meta: { 129 + lifecycle: defaultLifecycle() 130 + } 125 131 } 126 132 await mbTransformer.tryInitialize(); 127 133 ··· 148 154 album: "Fake", 149 155 isrc: 'GBAHT1600302' 150 156 }, 151 - meta: {} 157 + meta: { 158 + lifecycle: defaultLifecycle() 159 + } 152 160 } 153 161 await mbTransformer.tryInitialize(); 154 162 ··· 177 185 } 178 186 } 179 187 }, 180 - meta: {} 188 + meta: { 189 + lifecycle: defaultLifecycle() 190 + } 181 191 } 182 192 await mbTransformer.tryInitialize(); 183 193 ··· 209 219 } 210 220 } 211 221 }, 212 - meta: {} 222 + meta: { 223 + lifecycle: defaultLifecycle() 224 + } 213 225 } 214 226 await mbTransformer.tryInitialize(); 215 227 ··· 236 248 artists: ["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"], 237 249 album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions" 238 250 }, 239 - meta: {} 251 + meta: { 252 + lifecycle: defaultLifecycle() 253 + } 240 254 } 241 255 await mbTransformer.tryInitialize(); 242 256 ··· 258 272 track: "Undefeatable (feat. Kellin Quinn)", 259 273 artists: ["SEGA Sound Team / Tomoya Ohtani"], 260 274 }, 261 - meta: {} 275 + meta: { 276 + lifecycle: defaultLifecycle() 277 + } 262 278 } 263 279 await mbTransformer.tryInitialize(); 264 280 ··· 282 298 artists: ["、ナイトコードで。"], 283 299 album: "25時、ナイトコードで。 SEKAI ALBUM Vol.3" 284 300 }, 285 - meta: {} 301 + meta: { 302 + lifecycle: defaultLifecycle() 303 + } 286 304 } 287 305 await mbTransformer.tryInitialize(); 288 306 ··· 305 323 artists: ["Leo/need"], 306 324 album: "Leo / need SEKAI ALBUM Vol.1" 307 325 }, 308 - meta: {} 326 + meta: { 327 + lifecycle: defaultLifecycle() 328 + } 309 329 } 310 330 await mbTransformer.tryInitialize(); 311 331 ··· 333 353 album: "PERSONA5 ORIGINAL SOUNDTRACK", 334 354 isrc: 'JPK651601515' 335 355 }, 336 - meta: {} 356 + meta: { 357 + lifecycle: defaultLifecycle() 358 + } 337 359 } 338 360 await mbTransformer.tryInitialize(); 339 361 ··· 375 397 artists: ["Khruangbin"], 376 398 album: "The Universe Smiles Upon You ii" 377 399 }, 378 - meta: {} 400 + meta: { 401 + lifecycle: defaultLifecycle() 402 + } 379 403 } 380 404 await multiMb.tryInitialize(); 381 405 ··· 407 431 artists: ["Khruangbin"], 408 432 album: "The Universe Smiles Upon You ii" 409 433 }, 410 - meta: {} 434 + meta: { 435 + lifecycle: defaultLifecycle() 436 + } 411 437 } 412 438 await multiMb.tryInitialize(); 413 439
+4 -1
src/backend/tests/plex/plex.test.ts
··· 10 10 import { PlexApiData } from "../../common/infrastructure/config/source/plex.js"; 11 11 import PlexApiSource from "../../sources/PlexApiSource.js"; 12 12 import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; 13 + import { MarkOptional } from "ts-essentials"; 14 + import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; 13 15 14 16 const validSession = validSessionResponse.object.mediaContainer.metadata[0]; 15 17 ··· 33 35 platformId: ['1234', 'MyUser'], 34 36 play: generatePlay({}, {mediaType: 'track', user: 'MyUser', deviceId: '1234', library: 'Music'}) 35 37 } 36 - const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { 38 + const playWithMeta = (meta: MarkOptional<PlayMeta, 'lifecycle'>): PlayerStateDataMaybePlay => { 37 39 const {user, deviceId} = meta; 38 40 const platformId = validPlayerState.platformId; 39 41 return { ··· 42 44 play: { 43 45 ...validPlayerState.play, 44 46 meta: { 47 + lifecycle: defaultLifecycle(), 45 48 ...validPlayerState.play?.meta, 46 49 ...meta 47 50 }
+2 -2
src/backend/tests/scrobbler/TestScrobbler.ts
··· 21 21 return this.testRecentScrobbles; 22 22 } 23 23 24 - doScrobble(playObj: PlayObject): Promise<PlayObject> { 25 - return Promise.resolve(playObj); 24 + doScrobble(playObj: PlayObject) { 25 + return Promise.resolve({payload: {}, mergedScrobble: playObj}); 26 26 } 27 27 28 28 alreadyScrobbled = async (playObj: PlayObject, log?: boolean): Promise<boolean> => {
+3 -1
src/backend/tests/scrobbler/scrobblers.test.ts
··· 16 16 17 17 import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js"; 18 18 import { PlayPlatformId } from '../../common/infrastructure/Atomic.js'; 19 + import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; 19 20 20 21 chai.use(asPromised); 21 22 ··· 396 397 "playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27) 397 398 }, 398 399 meta: { 399 - source: 'Spotify' 400 + source: 'Spotify', 401 + lifecycle: defaultLifecycle() 400 402 } 401 403 } 402 404
+13 -3
src/backend/tests/utils/PlayTestUtils.ts
··· 13 13 import { ListRecord, ScrobbleRecord } from '../../common/infrastructure/config/client/tealfm.js'; 14 14 import { nanoid } from 'nanoid'; 15 15 import { LastFMTrackObject } from '../../common/vendor/LastfmApiClient.js'; 16 + import { MarkOptional } from 'ts-essentials'; 17 + import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; 16 18 17 19 dayjs.extend(utc) 18 20 dayjs.extend(isBetween); ··· 40 42 endDate?: Dayjs 41 43 defaultDuration?: number, 42 44 defaultData?: ObjectPlayData, 43 - defaultMeta?: PlayMeta 45 + defaultMeta?: MarkOptional<PlayMeta, 'lifecycle'> 44 46 } 45 47 ): PlayObject[] => { 46 48 const { ··· 80 82 ...defaultData 81 83 } 82 84 cleanPlay.meta = { 85 + lifecycle: defaultLifecycle(), 83 86 ...cleanPlay.meta, 84 87 ...defaultMeta 85 88 } ··· 155 158 } 156 159 } 157 160 158 - export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject => { 161 + export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}): PlayObject => { 159 162 return { 160 163 data: { 161 164 track: faker.music.songName(), ··· 168 171 meta: { 169 172 source: ['Spotify', 'Listenbrainz', 'Lastfm', 'Jellyfin', 'Plex'][faker.number.int({min: 0, max: 4})], 170 173 ...meta, 174 + lifecycle: { 175 + original: { 176 + data: {}, 177 + meta: {} 178 + }, 179 + steps: [] 180 + } 171 181 } 172 182 } 173 183 } ··· 221 231 return [did, uid]; 222 232 } 223 233 224 - export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject[] => { 234 + export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}): PlayObject[] => { 225 235 return Array.from(Array(numberOfPlays), () => generatePlay(data, meta)); 226 236 } 227 237
+8
src/backend/utils/DataUtils.ts
··· 1 1 import JSON5 from "json5"; 2 2 import { constants, promises } from "fs"; 3 3 import { MaybeLogger } from "../common/logging.js"; 4 + import { create as diffCreate } from 'jsondiffpatch'; 5 + 6 + export const jdiff = diffCreate({ 7 + propertyFilter(name, context) { 8 + return name !== 'lifecycle'; 9 + }, 10 + //omitRemovedValues: true 11 + }); 4 12 5 13 export const asArray = <T>(data: T | T[]): T[] => { 6 14 if (Array.isArray(data)) {
+4 -4
src/backend/utils/PlayComparisonUtils.ts
··· 1 1 import { getListDiff, ListDiff } from "@donedeal0/superdiff"; 2 - import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js"; 2 + import { PlayObject, PlayObjectLifecycleless, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js"; 3 3 import { buildTrackString } from "../../core/StringUtils.js"; 4 4 import { playObjDataMatch, setIntersection } from "../utils.js"; 5 5 import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js"; ··· 8 8 import { StringSamenessResult } from "@foxxmd/string-sameness"; 9 9 10 10 11 - export const metaInvariantTransform = (play: PlayObject): PlayObject => { 11 + export const metaInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { 12 12 const { 13 13 meta: { 14 14 trackId ··· 37 37 } 38 38 } 39 39 40 - export const playContentInvariantTransform = (play: PlayObject): PlayObject => { 40 + export const playContentInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { 41 41 const { 42 42 data: { 43 43 playDate, ··· 54 54 } 55 55 56 56 57 - export type PlayTransformer = (play: PlayObject) => PlayObject; 57 + export type PlayTransformer = (play: PlayObject) => PlayObjectLifecycleless; 58 58 export type ListTransformers = PlayTransformer[]; 59 59 60 60 export const defaultListTransformers: ListTransformers = [metaInvariantTransform, playDateInvariantTransform];
+30 -1
src/backend/utils/PlayTransformUtils.ts
··· 1 1 import { Logger, loggerTest } from "@foxxmd/logging"; 2 2 import { searchAndReplace as searchAndReplaceFunc, testMaybeRegex as testMaybeRegexFunc } from "@foxxmd/regex-buddy-core"; 3 - import { ObjectPlayData, PlayObject } from "../../core/Atomic.js"; 3 + import { ObjectPlayData, PlayLifecycle, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 4 4 import { buildTrackString } from "../../core/StringUtils.js"; 5 5 6 6 import { ··· 171 171 export interface TransformPlayPartsOptions { 172 172 logger?: () => Logger, 173 173 regex?: SuppliedRegex 174 + } 175 + 176 + export const baseFormatPlayObj = (data: any, play: PlayObjectLifecycleless): PlayObject => { 177 + return { 178 + data: { 179 + ...play.data 180 + }, 181 + meta: { 182 + ...play.meta, 183 + lifecycle: { 184 + input: data, 185 + original: play, 186 + steps: [] 187 + } 188 + } 189 + } 190 + } 191 + 192 + export const defaultLifecycle = (extra?: PlayLifecycle): PlayLifecycle => { 193 + const { 194 + original = {data: {}, meta: {}}, 195 + steps = [], 196 + ...rest 197 + } = extra ?? {}; 198 + return { 199 + original, 200 + steps, 201 + ...rest, 202 + } 174 203 }
+49
src/client/components/copyToClipboardHook.tsx
··· 1 + import { useCallback, useState } from "react"; 2 + 3 + export function useCopyToClipboard(resetInterval = 2000) { 4 + const [isCopied, setIsCopied] = useState(false); 5 + 6 + const copy = useCallback(async (text) => { 7 + if (!text) return false; 8 + 9 + let success = false; 10 + 11 + if (navigator.clipboard && window.isSecureContext) { 12 + try { 13 + await navigator.clipboard.writeText(text); 14 + success = true; 15 + } catch (err) { 16 + console.warn("Clipboard API failed:", err); 17 + } 18 + } 19 + 20 + // Fallback for insecure contexts 21 + if (!success) { 22 + const textarea = document.createElement("textarea"); 23 + textarea.value = text; 24 + textarea.style.position = "fixed"; 25 + textarea.style.opacity = "0"; 26 + document.body.appendChild(textarea); 27 + textarea.focus(); 28 + textarea.select(); 29 + 30 + try { 31 + success = document.execCommand("copy"); 32 + } catch (err) { 33 + console.error("Fallback copy failed:", err); 34 + success = false; 35 + } finally { 36 + document.body.removeChild(textarea); 37 + } 38 + } 39 + 40 + setIsCopied(success); 41 + if (success && resetInterval > 0) { 42 + setTimeout(() => setIsCopied(false), resetInterval); 43 + } 44 + 45 + return success; 46 + }, [resetInterval]); 47 + 48 + return { copy, isCopied }; 49 + }
+26 -3
src/client/deadLetter/DeadPage.tsx
··· 14 14 import dayjs from "dayjs"; 15 15 import {RootState} from "../store"; 16 16 import {connect, ConnectedProps} from "react-redux"; 17 + import { faBug } from "@fortawesome/free-solid-svg-icons"; 18 + import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 19 + import clsx from "clsx"; 20 + import { useCopyToClipboard } from '../components/copyToClipboardHook'; 17 21 18 22 const displayOpts = { 19 23 include: recentIncludes, ··· 30 34 isLoading, 31 35 isSuccess 32 36 } = useGetDeadQuery({name: searchParams.get('name'), type: searchParams.get('type')}); 37 + 38 + const { copy, isCopied } = useCopyToClipboard(); 39 + 40 + const [copiedIndex, setIndex] = useState(null); 41 + 42 + const copyActionCB = useCallback((obj, index) => { 43 + copy(JSON.stringify(obj, null, 2)); 44 + setIndex(index); 45 + },[copy, setIndex]); 46 + 47 + const baseClass = ['mr-3']; 33 48 34 49 const [removeDeadFetch, removeResult] = useRemoveDeadSingleMutation(); 35 50 const [retryDeadFetch, processResult] = useProcessDeadSingleMutation(); ··· 51 66 </div> 52 67 <div className="p-5"> 53 68 {isSuccess && !isLoading && data.length === 0 ? 'No failed scrobbles!' : null} 54 - <ul>{data.map(x => (<li className="my-2.5" key={x.id}> 55 - <div className="text-lg"><PlayDisplay data={x.play} buildOptions={displayOpts}/></div> 69 + <ul>{data.map(x => 70 + { 71 + const classes = [...baseClass].concat(copiedIndex !== x.id ? ['underline','cursor-pointer'] : []); 72 + return (<li className="my-2.5" key={x.id}> 73 + <div className="text-lg"> 74 + <button className={clsx(classes)} onClick={() => copyActionCB(x.play.meta.lifecycle, x.id)}>{copiedIndex === x.id ? 'Copied!' : <FontAwesomeIcon 75 + color="white" icon={faBug}/>}</button> 76 + <PlayDisplay data={x.play} buildOptions={displayOpts}/></div> 56 77 <div><span className="font-semibold">Source</span>:{x.source.replace('Source -', '')}</div> 57 78 <div><span className="font-semibold">Retries</span>: {x.retries}</div> 58 79 <div><span className="font-semibold">Last Retried</span>: {x.lastRetry === undefined ? 'Never' : dayjs.duration(dayjs(x.lastRetry).diff(dayjs())).humanize(true)}</div> 59 80 <div><span className="font-semibold">Error</span>: <span className="font-mono text-sm">{x.error}</span></div> 60 81 <div onClick={() => retryDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Retry</div> 61 82 <div onClick={() => removeDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Remove</div> 62 - </li>))}</ul> 83 + </li>) 84 + } 85 + )}</ul> 63 86 </div> 64 87 </div> 65 88 </div>
+34 -3
src/client/recent/RecentPage.tsx
··· 1 - import React, { Fragment, useMemo } from 'react'; 1 + import React, { Fragment, useMemo, useCallback, useState } from 'react'; 2 2 import PlayDisplay from "../components/PlayDisplay"; 3 3 import { recentIncludes } from "../../core/Atomic"; 4 4 import { useSearchParams } from "react-router-dom"; 5 5 import { useGetRecentQuery } from "./recentDucks"; 6 6 import Tooltip from "../components/Tooltip"; 7 - import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; 7 + import { faQuestionCircle, faBug } from "@fortawesome/free-solid-svg-icons"; 8 8 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 9 + import { useCopyToClipboard } from '../components/copyToClipboardHook'; 10 + import clsx from "clsx"; 11 + import {patch} from 'jsondiffpatch'; 9 12 10 13 const displayOpts = { 11 14 include: recentIncludes, ··· 42 45 upstream: searchParams.get('upstream') 43 46 }); 44 47 48 + const { copy, isCopied } = useCopyToClipboard(); 49 + 50 + const [copiedIndex, setIndex] = useState(null); 51 + 52 + const copyActionCB = useCallback((obj, index) => { 53 + copy(JSON.stringify(obj, null, 2)); 54 + setIndex(index); 55 + },[copy, setIndex]); 56 + 57 + const baseClass = ['mr-3']; 58 + 45 59 const isUpstream = searchParams.get('upstream') === '1'; 46 60 47 61 const tipContents = useMemo(() => { ··· 67 81 <div className="p-5"> 68 82 {/*{isUpstream ? <span className="mb-3">Below is data directly returned by the Source API. MS uses</span> : null}*/} 69 83 {isSuccess && !isLoading && data.length === 0 ? 'No recently played tracks!' : null} 70 - <ul>{data.map(x => <li key={x.index}><PlayDisplay data={x} buildOptions={displayOpts}/></li>)}</ul> 84 + <ul>{data.map(x => { 85 + const classes = [...baseClass].concat(copiedIndex !== x.index ? ['underline','cursor-pointer'] : []); 86 + // let last = x.meta.lifecycle.original; 87 + // x.meta.lifecycle.steps = x.meta.lifecycle.steps.map(y => { 88 + // if(y.patch !== undefined) { 89 + // // @ts-ignore 90 + // y.full = patch(last, y.patch); 91 + // // @ts-ignore 92 + // last = y.full; 93 + // } 94 + // return y; 95 + // }); 96 + return <li key={x.index}> 97 + <button className={clsx(classes)} onClick={() => copyActionCB(x.meta.lifecycle, x.index)}>{copiedIndex === x.index ? 'Copied!' : <FontAwesomeIcon 98 + color="white" icon={faBug}/>}</button> 99 + <PlayDisplay data={x} buildOptions={displayOpts}/> 100 + </li> 101 + })}</ul> 71 102 </div> 72 103 </div> 73 104 </div>
+26 -2
src/client/scrobbled/ScrobbledPage.tsx
··· 1 - import React from 'react'; 1 + import React, {useCallback, useState} from 'react'; 2 2 import PlayDisplay from "../components/PlayDisplay"; 3 3 import {recentIncludes} from "../../core/Atomic"; 4 4 import {useSearchParams} from "react-router-dom"; 5 5 import {useGetRecentQuery} from "./scrobbledDucks"; 6 + import { useCopyToClipboard } from '../components/copyToClipboardHook'; 7 + import { faBug } from "@fortawesome/free-solid-svg-icons"; 8 + import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 9 + import clsx from "clsx"; 6 10 7 11 const displayOpts = { 8 12 include: recentIncludes, ··· 18 22 isSuccess 19 23 } = useGetRecentQuery({name: searchParams.get('name'), type: searchParams.get('type')}); 20 24 25 + const { copy, isCopied } = useCopyToClipboard(); 26 + 27 + const [copiedIndex, setIndex] = useState(null); 28 + 29 + const copyActionCB = useCallback((obj, index) => { 30 + copy(JSON.stringify(obj, null, 2)); 31 + setIndex(index); 32 + },[copy, setIndex]); 33 + 34 + const baseClass = ['mr-3']; 35 + 36 + 21 37 return ( 22 38 <div className="grid"> 23 39 <div className="shadow-md rounded bg-gray-500 text-white"> ··· 27 43 </div> 28 44 <div className="p-5"> 29 45 {isSuccess && !isLoading && data.length === 0 ? 'No recently scrobbled tracks!' : null} 30 - <ul>{data.map(x => <li key={x.index}><PlayDisplay data={x} buildOptions={displayOpts}/></li>)}</ul> 46 + <ul>{data.map(x => { 47 + const classes = [...baseClass].concat(copiedIndex !== x.index ? ['underline','cursor-pointer'] : []); 48 + 49 + return <li key={x.index}> 50 + <button className={clsx(classes)} onClick={() => copyActionCB(x.meta.lifecycle, x.index)}>{copiedIndex === x.index ? 'Copied!' : <FontAwesomeIcon 51 + color="white" icon={faBug}/>}</button> 52 + <PlayDisplay data={x} buildOptions={displayOpts}/> 53 + </li>; 54 + })} </ul> 31 55 </div> 32 56 </div> 33 57 </div>
+53
src/core/Atomic.ts
··· 2 2 import { Dayjs } from "dayjs"; 3 3 import { ListenProgress } from "../backend/sources/PlayerState/ListenProgress.js"; 4 4 import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; 5 + import { Delta } from 'jsondiffpatch'; 6 + import { MarkOptional } from "ts-essentials"; 7 + import { ErrorObject } from "serialize-error"; 5 8 6 9 export interface SourceStatusData { 7 10 status: string; ··· 262 265 263 266 comment?: string 264 267 268 + lifecycle: PlayLifecycle 269 + lifecycleInputs?: LifecycleInput[] 270 + 265 271 [key: string]: any 266 272 } 267 273 274 + export interface LifecycleInput { 275 + type: string, input: (object | string) 276 + } 277 + 278 + export interface PlayObjectLifecycleless { 279 + data: ObjectPlayData, 280 + meta: MarkOptional<PlayMeta, 'lifecycle'> 281 + } 282 + 283 + // export interface PlayMetaLifecycled extends PlayMeta { 284 + // lifecycle: PlayLifecycle 285 + // } 286 + 287 + export interface PlayLifecycle { 288 + input?: object 289 + original: PlayObjectLifecycleless 290 + steps: LifecycleStep[] 291 + scrobble?: { 292 + payload?: ScrobblePayload 293 + warnings?: string[] 294 + error?: Error | ErrorObject 295 + response?: ScrobbleResponse 296 + mergedScrobble?: PlayObjectLifecycleless 297 + } 298 + } 299 + 300 + export interface LifecycleStep { 301 + name: string 302 + source: string 303 + patch?: Delta 304 + inputs?: LifecycleInput[] 305 + } 306 + 307 + export type ScrobblePayload = object | string; 308 + export type ScrobbleResponse = object | string; 309 + 310 + export interface ScrobbleActionResult { 311 + payload: ScrobblePayload, 312 + response?: ScrobbleResponse, 313 + mergedScrobble?: PlayObject 314 + warnings?: string[] 315 + } 316 + 268 317 export type ScrobbleTsSOC = 1 | 2; 269 318 270 319 export const SCROBBLE_TS_SOC_START: ScrobbleTsSOC = 1; ··· 282 331 export interface PlayObject extends AmbPlayObject { 283 332 data: ObjectPlayData, 284 333 } 334 + 335 + // export interface PlayObjectLifecycled extends PlayObject { 336 + // meta: PlayMetaLifecycled 337 + // } 285 338 286 339 export interface JsonPlayObject extends AmbPlayObject { 287 340 data: JsonPlayData