diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index 1b9446a5..2e42de88 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -302,7 +302,7 @@ Find your [**Plex Token**](https://support.plex.tv/articles/204059436-finding-an :::warning[Deprecated] -Multi-scrobbler < 0.9.0 used [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to support Plex scrobbling. This approach has been deprecated in favor of using Plex's API directly which has many benefits including **not requiring Plex Pass.** +Multi-scrobbler < 0.8.7 used [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to support Plex scrobbling. This approach has been deprecated in favor of using Plex's API directly which has many benefits including **not requiring Plex Pass.** ::: @@ -312,9 +312,9 @@ Multi-scrobbler < 0.9.0 used [webhooks](https://support.plex.tv/articles/1150022 * Follow the instructions in the API tab * The `user` (`PLEX_USER`) setting has been renamed `usersAllow` (`PLEX_USERS_ALLOW`) - * If you were using this filter to ensure only scrobbles from yourself were registered then you no longer need this setting -- by default MS will only scrobble for the user the Plex Token is used for. - * The `servers` setting is no longer available as MS only scrobbles from the server using the API anyways. - * If you need to scrobble for multiple servers set up each server as a separate Plex API source + * If you were using this filter to ensure only scrobbles from yourself were registered then you no longer need this setting -- by default MS will only scrobble for the user the Plex Token is from. + * The `servers` setting is no longer available as MS only scrobbles from the server the Plex token is from. + * If you need to scrobble for multiple servers set up each server as a separate Plex API source with a separate token. * The `libraries` setting has been renamed to `librariesAllow` diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 11b28b64..e87d6882 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -116,6 +116,8 @@ export interface PlayerStateData extends PlayerStateDataMaybePlay { export interface PlayerStateDataMaybePlay { platformId: PlayPlatformId + /** The ID/Key for individual sessions on a device/platform */ + sessionId?: string play?: PlayObject status?: ReportedPlayerStatus position?: number diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 94f4851b..99d7eec3 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -2,8 +2,7 @@ import { stringSameness } from '@foxxmd/string-sameness'; import dayjs from "dayjs"; import request, { Request, Response } from 'superagent'; import { PlayObject } from "../../../core/Atomic.js"; -import { slice } from "../../../core/StringUtils.js"; -import { combinePartsToString } from "../../utils.js"; +import { combinePartsToString, slice } from "../../../core/StringUtils.js"; import { findDelimiters, normalizeStr, diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index 7c50486b..ffd8836f 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -41,7 +41,7 @@ import { nanoid } from "nanoid"; import pEvent from "p-event"; import { Simulate } from "react-dom/test-utils"; import { PlayObject } from "../../core/Atomic.js"; -import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; +import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -50,7 +50,7 @@ import { PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../common/infrastructure/Atomic.js"; import { JellyApiSourceConfig } from "../common/infrastructure/config/source/jellyfin.js"; -import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; +import { genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { FixedSizeList } from "fixed-size-list"; diff --git a/src/backend/sources/JellyfinSource.ts b/src/backend/sources/JellyfinSource.ts index 0ce30c88..37a8e420 100644 --- a/src/backend/sources/JellyfinSource.ts +++ b/src/backend/sources/JellyfinSource.ts @@ -2,11 +2,15 @@ import { Logger } from "@foxxmd/logging"; import dayjs from "dayjs"; import EventEmitter from "events"; import { PlayObject, TA_CLOSE } from "../../core/Atomic.js"; -import { buildTrackString, splitByFirstFound, truncateStringToLength } from "../../core/StringUtils.js"; +import { + buildTrackString, + combinePartsToString, + splitByFirstFound, + truncateStringToLength +} from "../../core/StringUtils.js"; import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; import { JellySourceConfig } from "../common/infrastructure/config/source/jellyfin.js"; import { - combinePartsToString, doubleReturnNewline, parseBool, parseDurationFromTimestamp, diff --git a/src/backend/sources/MemoryPositionalSource.ts b/src/backend/sources/MemoryPositionalSource.ts index c6e8d2d7..b8e07790 100644 --- a/src/backend/sources/MemoryPositionalSource.ts +++ b/src/backend/sources/MemoryPositionalSource.ts @@ -1,8 +1,9 @@ import { Logger } from "@foxxmd/logging"; -import { PlayPlatformId } from "../common/infrastructure/Atomic.js"; +import { PlayerStateDataMaybePlay, PlayPlatformId } from "../common/infrastructure/Atomic.js"; import MemorySource from "./MemorySource.js"; import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; import { PositionalPlayerState } from "./PlayerState/PositionalPlayerState.js"; +import { PlayObject } from "../../core/Atomic.js"; export class MemoryPositionalSource extends MemorySource { getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PositionalPlayerState(logger, id, opts) diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index ead84e03..5d971d1a 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -122,6 +122,13 @@ export default class MemorySource extends AbstractSource { this.emitEvent('playerDelete', {platformId: id}); } + pickPlatformSession = (sessions: (PlayObject | PlayerStateDataMaybePlay)[], player: AbstractPlayerState): PlayObject | PlayerStateDataMaybePlay => { + if(sessions.length > 1) { + player.logger.debug(`More than one data/state found in incoming data, will only use first found.`); + } + return sessions[0]; + } + processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[]) => { const { @@ -162,10 +169,7 @@ export default class MemorySource extends AbstractSource { if (relevantDatas.length > 0) { this.lastActivityAt = dayjs(); - if (relevantDatas.length > 1) { - this.logger.warn(`More than one data/state for Player ${player.platformIdStr} found in incoming data, will only use first found.`); - } - incomingData = relevantDatas[0]; + incomingData = this.pickPlatformSession(relevantDatas, player); let playerState: PlayerStateDataMaybePlay; if(asPlayerStateDataMaybePlay(incomingData)) { diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 895b795b..fa4b788b 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -53,6 +53,7 @@ export abstract class AbstractPlayerState { reportedStatus: ReportedPlayerStatus = REPORTED_PLAYER_STATUSES.unknown calculatedStatus: CalculatedPlayerStatus = CALCULATED_PLAYER_STATUSES.unknown platformId: PlayPlatformId + sessionId?: string stateIntervalOptions: Required; currentPlay?: PlayObject playFirstSeenAt?: Dayjs @@ -151,11 +152,12 @@ export abstract class AbstractPlayerState { } protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] { - const {play, status} = state; + const {play, status, sessionId} = state; this.playLastUpdatedAt = dayjs(); if (status !== undefined) { this.reportedStatus = status; } + this.sessionId = sessionId; if (this.currentPlay !== undefined) { if (!this.incomingPlayMatchesExisting(play)) { // TODO check new play date and listen range to see if they intersect @@ -329,7 +331,7 @@ export abstract class AbstractPlayerState { this.listenRanges = []; this.currentListenRange = undefined; - this.logger.debug(`New Play: ${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}`); + this.logger.verbose(`New Play: ${buildTrackString(play, {include: ['trackId', 'artist', 'track', 'session']})}`); if (status !== undefined) { this.reportedStatus = status; @@ -344,7 +346,7 @@ export abstract class AbstractPlayerState { const parts = ['']; let play: string; if (this.currentPlay !== undefined) { - parts.push(`${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})} @ ${this.playFirstSeenAt.toISOString()}`); + parts.push(`${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track', 'session']})} @ ${this.playFirstSeenAt.toISOString()}`); } parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Last Update: ${this.stateLastUpdatedAt.toISOString()}`); let progress = ''; diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 6248328e..5c0fcab8 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -1,16 +1,17 @@ import objectHash from 'object-hash'; import EventEmitter from "events"; import { PlayObject } from "../../core/Atomic.js"; -import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; +import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { + asPlayerStateDataMaybePlay, FormatPlayObjectOptions, InternalConfig, PlayerStateData, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../common/infrastructure/Atomic.js"; -import { combinePartsToString, genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; -import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; +import { genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; +import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.js"; import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; import { PlexAPI } from "@lukehagar/plexjs"; import { @@ -23,7 +24,7 @@ import { GetTokenDetailsResponse, GetTokenDetailsUserPlexAccount } from '@lukeha import { parseRegexSingle } from '@foxxmd/regex-buddy-core'; import { Readable } from 'node:stream'; import { PlexPlayerState } from './PlayerState/PlexPlayerState.js'; -import { PlayerStateOptions } from './PlayerState/AbstractPlayerState.js'; +import { AbstractPlayerState, PlayerStateOptions } from './PlayerState/AbstractPlayerState.js'; import { Logger } from '@foxxmd/logging'; import { MemoryPositionalSource } from './MemoryPositionalSource.js'; import { FixedSizeList } from 'fixed-size-list'; @@ -291,6 +292,7 @@ export default class PlexApiSource extends MemoryPositionalSource { librarySectionTitle: library, duration, guid, + sessionKey, player: { product, title: playerTitle, @@ -320,6 +322,7 @@ export default class PlexApiSource extends MemoryPositionalSource { source: 'Plex', library, deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]), + sessionId: sessionKey, trackProgressPosition: viewOffset / 1000 } } @@ -329,19 +332,16 @@ export default class PlexApiSource extends MemoryPositionalSource { const result = await this.plexApi.sessions.getSessions(); - const nonMSSessions: [PlayerStateDataMaybePlay, GetSessionsMetadata][] = (result.object.mediaContainer?.metadata ?? []) + const allSessions: [PlayerStateDataMaybePlay, GetSessionsMetadata][] = (result.object.mediaContainer?.metadata ?? []) .map(x => [this.sessionToPlayerState(x), x]); const validSessions: PlayerStateDataMaybePlay[] = []; - for(const sessionData of nonMSSessions) { + for(const sessionData of allSessions) { const validPlay = this.isActivityValid(sessionData[0], sessionData[1]); if(validPlay === true) { validSessions.push(sessionData[0]); } else if(this.logFilterFailure !== false) { - let stateIdentifyingInfo: string = genGroupIdStr(getPlatformIdFromData(sessionData[0])); - if(sessionData[0].play !== undefined) { - stateIdentifyingInfo = buildTrackString(sessionData[0].play, {include: ['artist', 'track', 'platform']}); - } + const stateIdentifyingInfo = buildStatePlayerPlayIdententifyingInfo(sessionData[0]); const dropReason = `Player State for -> ${stateIdentifyingInfo} <-- is being dropped because ${validPlay}`; if(!this.uniqueDropReasons.data.some(x => x === dropReason)) { this.logger[this.logFilterFailure](dropReason); @@ -370,6 +370,25 @@ export default class PlexApiSource extends MemoryPositionalSource { } } + pickPlatformSession = (sessions: (PlayObject | PlayerStateDataMaybePlay)[], player: AbstractPlayerState): PlayObject | PlayerStateDataMaybePlay => { + if(sessions.length === 1) { + return sessions[0]; + } + // if all are player states and have session ids + // then choose the player state with the "latest" session key + if(sessions.every(x => asPlayerStateDataMaybePlay(x) && 'sessionId' in x)) { + const pStateSessions = sessions as PlayerStateDataMaybePlay[]; + pStateSessions.sort((a, b) => parseInt(a.sessionId) - parseInt(b.sessionId)); + + const validSession = pStateSessions[sessions.length - 1]; + const droppingSessions = pStateSessions.filter(x => x.sessionId !== validSession.sessionId).map(x => buildStatePlayerPlayIdententifyingInfo(x)).join('\n'); + player.logger.debug(`More than one data/state found in incoming data, dropping these sessions with "earlier" session keys:\n${droppingSessions}`); + + return validSession; + } + return sessions[0]; + } + sessionToPlayerState = (obj: GetSessionsMetadata): PlayerStateDataMaybePlay => { const { @@ -379,7 +398,8 @@ export default class PlexApiSource extends MemoryPositionalSource { product, title, state - } = {} + } = {}, + sessionKey } = obj; const msDeviceId = combinePartsToString([shortDeviceId(machineIdentifier), product, title]); @@ -394,6 +414,7 @@ export default class PlexApiSource extends MemoryPositionalSource { const reportedStatus = state !== 'playing' ? REPORTED_PLAYER_STATUSES.paused : REPORTED_PLAYER_STATUSES.playing; return { platformId: [msDeviceId, play.meta.user], + sessionId: sessionKey, play, status: reportedStatus, position: viewOffset / 1000 diff --git a/src/backend/sources/PlexSource.ts b/src/backend/sources/PlexSource.ts index 52036167..899f4192 100644 --- a/src/backend/sources/PlexSource.ts +++ b/src/backend/sources/PlexSource.ts @@ -5,10 +5,9 @@ import EventEmitter from "events"; import formidable, { Files, File } from 'formidable'; import { file } from "jscodeshift"; import { PlayObject } from "../../core/Atomic.js"; -import { truncateStringToLength } from "../../core/StringUtils.js"; +import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { FormatPlayObjectOptions, InternalConfig, SourceType } from "../common/infrastructure/Atomic.js"; import { PlexSourceConfig } from "../common/infrastructure/config/source/plex.js"; -import { combinePartsToString } from "../utils.js"; import { getFileIdentifier, getValidMultipartJsonFile } from "../utils/RequestUtils.js"; import AbstractSource from "./AbstractSource.js"; diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 066213a8..80200773 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -3,7 +3,7 @@ import EventEmitter from "events"; import SpotifyWebApi from "spotify-web-api-node"; import request from 'superagent'; import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC } from "../../core/Atomic.js"; -import { truncateStringToLength } from "../../core/StringUtils.js"; +import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; import { @@ -18,7 +18,6 @@ import { } from "../common/infrastructure/Atomic.js"; import { SpotifySourceConfig } from "../common/infrastructure/config/source/spotify.js"; import { - combinePartsToString, joinedUrl, parseRetryAfterSecsFromObj, readJson, diff --git a/src/backend/sources/TautulliSource.ts b/src/backend/sources/TautulliSource.ts index 86115480..48c7ed6e 100644 --- a/src/backend/sources/TautulliSource.ts +++ b/src/backend/sources/TautulliSource.ts @@ -2,10 +2,9 @@ import dayjs from "dayjs"; import EventEmitter from "events"; import { Request } from "express"; import { PlayObject } from "../../core/Atomic.js"; -import { truncateStringToLength } from "../../core/StringUtils.js"; +import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { TautulliSourceConfig } from "../common/infrastructure/config/source/tautulli.js"; -import { combinePartsToString } from "../utils.js"; import PlexSource from "./PlexSource.js"; const shortDeviceId = truncateStringToLength(10, ''); diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 29b73ddc..2769a608 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -16,12 +16,10 @@ import pathUtil from "path"; import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js"; import { PlayObject } from "../core/Atomic.js"; import { - asPlayerStateData, asPlayerStateDataMaybePlay, NO_DEVICE, NO_USER, numberFormatOptions, - PlayerStateData, PlayerStateDataMaybePlay, PlayPlatformId, ProgressAwarePlayObject, @@ -365,34 +363,6 @@ export const remoteHostStr = (req: Request): string => { return `${host}${proxy !== undefined ? ` (${proxy})` : ''}${agent !== undefined ? ` (UA: ${agent})` : ''}`; } -export const combinePartsToString = (parts: any[], glue: string = '-'): string | undefined => { - const cleanParts: string[] = []; - for (const part of parts) { - if (part === null || part === undefined) { - continue; - } - if (Array.isArray(part)) { - const nestedParts = combinePartsToString(part, glue); - if (nestedParts !== undefined) { - cleanParts.push(nestedParts); - } - } else if (typeof part === 'object') { - // hope this works - cleanParts.push(JSON.stringify(part)); - } else if(typeof part === 'string') { - if(part.trim() !== '') { - cleanParts.push(part); - } - } else { - cleanParts.push(part.toString()); - } - } - if (cleanParts.length > 0) { - return cleanParts.join(glue); - } - return undefined; -} - /** * Remove duplicates based on trackId, deviceId, and play date * */ @@ -759,4 +729,4 @@ export const getFirstNonEmptyVal = (values: unknown[], options: {of return undefined; } -export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal(values, {ofType: 'string', test: (v) => v.trim() !== ''}); \ No newline at end of file +export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal(values, {ofType: 'string', test: (v) => v.trim() !== ''}); diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index f2a3d270..24cc76a7 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -1,7 +1,8 @@ import { strategies, stringSameness, StringSamenessResult } from "@foxxmd/string-sameness"; import { PlayObject } from "../../core/Atomic.js"; -import { DELIMITERS } from "../common/infrastructure/Atomic.js"; -import { parseRegexSingleOrFail } from "../utils.js"; +import { asPlayerStateData, DELIMITERS, PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.js"; +import { genGroupIdStr, getPlatformIdFromData, parseRegexSingleOrFail } from "../utils.js"; +import { buildTrackString } from "../../core/StringUtils.js"; const {levenStrategy, diceStrategy} = strategies; @@ -357,3 +358,11 @@ export const firstNonEmptyStr = (vals: unknown[]): string | undefined => { } } } + +export const buildStatePlayerPlayIdententifyingInfo = (data: PlayObject | PlayerStateDataMaybePlay): string => { + let idInfo = genGroupIdStr(getPlatformIdFromData(data)); + if(asPlayerStateData(data)) { + idInfo = buildTrackString(data.play, {include: ['artist', 'track', 'platform', 'session']}); + } + return idInfo; +} diff --git a/src/client/components/player/PlayerInfo.tsx b/src/client/components/player/PlayerInfo.tsx index 00c7adc7..f7c94692 100644 --- a/src/client/components/player/PlayerInfo.tsx +++ b/src/client/components/player/PlayerInfo.tsx @@ -14,6 +14,11 @@ const PlayerInfo = (props: PlayerInfoProps) => { data, data: { play, + play: { + meta: { + sessionId + } = {} + } = {}, status: { calculated, reported @@ -34,7 +39,7 @@ const PlayerInfo = (props: PlayerInfoProps) => { return (
-
Player ID: {data.platformId}
+
Player ID: {data.platformId}{sessionId !== undefined ? ` (Session ${sessionId})` : null}
Player Updated: {isoToHuman(data.playerLastUpdatedAt, {includeRelative: true})}
Track Seen: {isoToHuman(data.playFirstSeenAt, {includeRelative: true})}
Track Updated: {isoToHuman(data.playLastUpdatedAt, {includeRelative: true})}
diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index ae1f46ff..f13ea5cc 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -31,7 +31,7 @@ export interface ClientStatusData { initialized: boolean; } -export type PlayObjectIncludeTypes = 'album' | 'time' | 'artist' | 'track' | 'timeFromNow' | 'trackId' | 'comment' | 'platform'; +export type PlayObjectIncludeTypes = 'album' | 'time' | 'artist' | 'track' | 'timeFromNow' | 'trackId' | 'comment' | 'platform' | 'session'; export const recentIncludes: PlayObjectIncludeTypes[] = ['time', 'timeFromNow', 'track', 'album', 'artist', 'comment']; export interface TrackStringOptions { @@ -43,7 +43,7 @@ export interface TrackStringOptions { time?: (t: Dayjs, i?: ScrobbleTsSOC) => T | string timeFromNow?: (t: Dayjs) => T | string comment?: (c: string | undefined) => T | string - platform?: (d: string | undefined, u: string | undefined) => T | string + platform?: (d: string | undefined, u: string | undefined, s: string | undefined) => T | string reducer?: (arr: (T | string)[]) => T //(acc: T, curr: T | string) => T } } @@ -152,6 +152,8 @@ export interface PlayMeta { * A unique identifier for the device playing this track * */ deviceId?: string + /** The ID/Key for individual sessions on a device/platform */ + sessionId?: string nowPlaying?: boolean diff --git a/src/core/StringUtils.ts b/src/core/StringUtils.ts index 1977da8a..ca958481 100644 --- a/src/core/StringUtils.ts +++ b/src/core/StringUtils.ts @@ -38,7 +38,7 @@ export const defaultTimeFunc = (t: Dayjs | undefined, i?: ScrobbleTsSOC) => t == export const defaultTimeFromNowFunc = (t: Dayjs | undefined) => t === undefined ? undefined : `(${t.local().fromNow()})`; export const defaultCommentFunc = (c: string | undefined) => c === undefined ? undefined : `(${c})`; // TODO replace with genGroupIdStr and refactor Platform types/etc. into core Atomic -export const defaultPlatformFunc = (d: string | undefined, u: string | undefined) => `${d ?? 'NoDevice'}-${u ?? 'SingleUser'}`; +export const defaultPlatformFunc = (d: string | undefined, u: string | undefined, s: string | undefined) => combinePartsToString([d ?? 'NoDevice', u ?? 'SingleUser',s !== undefined ? `Session${s}` : undefined]); export const defaultBuildTrackStringTransformers = { artists: defaultArtistFunc, track: defaultTrackTransformer, @@ -75,7 +75,8 @@ export const buildTrackString = (playObj: AmbPlayObject, options: Tr scrobbleTsSOC = SCROBBLE_TS_SOC_START, comment, deviceId, - user + user, + sessionId } = {}, } = playObj; @@ -90,7 +91,9 @@ export const buildTrackString = (playObj: AmbPlayObject, options: Tr const strParts: (T | string)[] = []; if(include.includes('platform')) { - strParts.push(platformFunc(deviceId, user)) + strParts.push(platformFunc(deviceId, user, include.includes('session') ? sessionId : undefined)) + } else if(include.includes('session') && sessionId !== undefined) { + strParts.push(`(Session ${sessionId})`); } if (include.includes('trackId') && trackId !== undefined) { strParts.push(`(${trackId})`); @@ -166,3 +169,30 @@ export const nonEmptyStringOrDefault = (str: any, defaultVal: T = undefined): } return str; } +export const combinePartsToString = (parts: any[], glue: string = '-'): string | undefined => { + const cleanParts: string[] = []; + for (const part of parts) { + if (part === null || part === undefined) { + continue; + } + if (Array.isArray(part)) { + const nestedParts = combinePartsToString(part, glue); + if (nestedParts !== undefined) { + cleanParts.push(nestedParts); + } + } else if (typeof part === 'object') { + // hope this works + cleanParts.push(JSON.stringify(part)); + } else if (typeof part === 'string') { + if (part.trim() !== '') { + cleanParts.push(part); + } + } else { + cleanParts.push(part.toString()); + } + } + if (cleanParts.length > 0) { + return cleanParts.join(glue); + } + return undefined; +}