diff --git a/src/backend/common/vendor/JRiverApiClient.ts b/src/backend/common/vendor/JRiverApiClient.ts index 8da392e3..ffc73575 100644 --- a/src/backend/common/vendor/JRiverApiClient.ts +++ b/src/backend/common/vendor/JRiverApiClient.ts @@ -96,6 +96,8 @@ export class JRiverApiClient extends AbstractApiClient { token?: string; + version?: string; + constructor(name: any, config: JRiverData, options: AbstractApiOptions) { super('JRiver', name, config, options); const { @@ -130,6 +132,7 @@ export class JRiverApiClient extends AbstractApiClient { try { const resp = await this.callApi(request.get(`${this.url}Alive`)); const {body: { data } = {}} = resp; + this.version = data.ProgramVersion; this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`); return true; } catch (e) { diff --git a/src/backend/common/vendor/KodiApiClient.ts b/src/backend/common/vendor/KodiApiClient.ts index c53e331a..5676f09c 100644 --- a/src/backend/common/vendor/KodiApiClient.ts +++ b/src/backend/common/vendor/KodiApiClient.ts @@ -46,6 +46,8 @@ export class KodiApiClient extends AbstractApiClient { token?: string; + version?: string; + declare client: KodiClient; constructor(name: any, config: KodiData, options: AbstractApiOptions) { @@ -84,7 +86,7 @@ export class KodiApiClient extends AbstractApiClient { return url; } - static formatPlayObj(obj: (PlayerItem & Partial & { playerid?: number }), options: FormatPlayObjectOptions = {}): PlayObject { + static formatPlayObj(obj: (PlayerItem & Partial & { playerid?: number }), options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject { const {newFromSource = true} = options; const { @@ -113,6 +115,8 @@ export class KodiApiClient extends AbstractApiClient { }, meta: { source: 'kodi', + mediaPlayerName: 'Kodi', + mediaPlayerVersion: options.version, trackId: id.toString(), newFromSource, trackProgressPosition, @@ -135,6 +139,7 @@ export class KodiApiClient extends AbstractApiClient { // https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties const applicationInfo = await this.client.Application.GetProperties(['version']); + this.version = `${applicationInfo.version.major}.${applicationInfo.version.minor}`; this.logger.info(`Found Kodi v${applicationInfo.version.major}.${applicationInfo.version.minor} (JSONRPC v${jsonInfo.version.major}.${jsonInfo.version.minor})`); return true; } catch (e) { @@ -180,7 +185,7 @@ export class KodiApiClient extends AbstractApiClient { const itemInfo = await this.getPlayerItem(audioActive.playerid); - const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive}); + const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive}, {version: this.version}); return [play]; } diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 09a3c913..e3bf764c 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -18,7 +18,7 @@ import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js"; import AbstractApiClient from "./AbstractApiClient.js"; import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js'; -import { parseRegexSingleOrFail } from '../../utils.js'; +import { parseRegexSingleOrFail, removeUndefinedKeys } from '../../utils.js'; import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js' import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js'; import { log } from 'console'; @@ -713,34 +713,34 @@ export const playToListenPayload = (play: PlayObject): ListenPayload => { duration, meta: { brainz = {}, - spotify = {} + spotify = {}, } = {} + }, + meta: { + mediaPlayerName, + mediaPlayerVersion, + musicService, } } = play; // using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens // which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification // and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941 - const addInfo: SubmitListenAdditionalTrackInfo = { + let addInfo: SubmitListenAdditionalTrackInfo = { // all artists artist_names: Array.from(new Set([...artists, ...albumArtists])), // primary artist release_artist_name: artists[0], release_artist_names: [artists[0]], + media_player: mediaPlayerName, + media_player_version: mediaPlayerVersion, + music_service: musicService !== undefined ? musicServiceToCononical(musicService) : undefined, + spotify_id: spotify.track, + spotify_album_id: spotify.album, + spotify_artist_ids: spotify.artist }; - if(spotify.track !== undefined) { - addInfo.spotify_id = spotify.track; - } - if(spotify.album !== undefined) { - addInfo.spotify_album_id = spotify.album; - } - if(spotify.albumArtist !== undefined && spotify.albumArtist.length > 0) { - addInfo.spotify_album_artist_ids = spotify.albumArtist; - } - if(spotify.artist !== undefined) { - addInfo.spotify_artist_ids = spotify.artist; - } + addInfo = removeUndefinedKeys(addInfo) return { listened_at: getScrobbleTsSOCDate(play).unix(), @@ -760,4 +760,31 @@ export const playToListenPayload = (play: PlayObject): ListenPayload => { } } } - } \ No newline at end of file + } + +const musicServices = { + spotify: 'spotify.com', + bandcamp: 'bandcamp.com', + ['youtube music']: 'music.youtube.com', + youtube: 'youtube.com', + deezer: 'deezer.com', + tidal: 'tidal.com', + apple: 'music.apple.com', + archive: 'archive.org', + soundcloud: 'soundcloud.com', + jamendo: 'jamendo.com', + play: 'play.google.com' +} +/** + * Converts MS musicService to LZ cononical Music Service Name, if one exists + * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details + * */ +const musicServiceToCononical = (str: string): string | undefined => { + const lower = str.trim().toLocaleLowerCase(); + for(const [k, v] of Object.entries(musicServices)) { + if(lower.includes(k)) { + return v; + } + } + return undefined; +} \ No newline at end of file diff --git a/src/backend/common/vendor/webscrobbler/interfaces.ts b/src/backend/common/vendor/webscrobbler/interfaces.ts index 8953e845..ba0699bc 100644 --- a/src/backend/common/vendor/webscrobbler/interfaces.ts +++ b/src/backend/common/vendor/webscrobbler/interfaces.ts @@ -61,8 +61,14 @@ export type Metadata = } | Record; +export interface Connector { + id: string + js: string + label: string +} export interface WebScrobblerSong { controllerTabId: string | number; + connector: Connector parsed: ParsedSongData; processed: ProcessedSongData; noRegex: ProcessedSongData; diff --git a/src/backend/sources/AzuracastSource.ts b/src/backend/sources/AzuracastSource.ts index d7bfa19a..b9c8b9bf 100644 --- a/src/backend/sources/AzuracastSource.ts +++ b/src/backend/sources/AzuracastSource.ts @@ -255,7 +255,9 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp duration }, meta: { - trackProgressPosition: elapsed + trackProgressPosition: elapsed, + source: 'azuracast', + mediaPlayerName: 'Azuracast' } } } diff --git a/src/backend/sources/DeezerInternalSource.ts b/src/backend/sources/DeezerInternalSource.ts index 9e9dfd64..a02ece7f 100644 --- a/src/backend/sources/DeezerInternalSource.ts +++ b/src/backend/sources/DeezerInternalSource.ts @@ -80,7 +80,8 @@ export default class DeezerInternalSource extends MemorySource { newFromSource, url: { web: `https://www.deezer.com/track/${obj.SNG_ID}` - } + }, + musicService: 'Deezer', } }; if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') { diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index dc7ecd05..14210540 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -81,6 +81,7 @@ export default class DeezerSource extends AbstractSource { }, meta: { source: 'Deezer', + musicService: 'Deezer', trackId: id, newFromSource, url: { diff --git a/src/backend/sources/IcecastSource.ts b/src/backend/sources/IcecastSource.ts index ad46e005..033b3d0b 100644 --- a/src/backend/sources/IcecastSource.ts +++ b/src/backend/sources/IcecastSource.ts @@ -213,7 +213,8 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = artists }, meta: { - source: 'icecast' + source: 'icecast', + mediaPlayerName: 'Icecast' } } } \ No newline at end of file diff --git a/src/backend/sources/JRiverSource.ts b/src/backend/sources/JRiverSource.ts index 12e478ea..35866244 100644 --- a/src/backend/sources/JRiverSource.ts +++ b/src/backend/sources/JRiverSource.ts @@ -81,7 +81,7 @@ export class JRiverSource extends MemoryPositionalSource { } } - static formatPlayObj(obj: Info, options: FormatPlayObjectOptions = {}): PlayObject { + static formatPlayObj(obj: Info, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject { const {newFromSource = true} = options; const { @@ -109,6 +109,8 @@ export class JRiverSource extends MemoryPositionalSource { }, meta: { source: 'mopidy', + mediaPlayerName: 'JRiver', + mediaPlayerVersion: options.version, trackId: FileKey, newFromSource, trackProgressPosition: trackProgressPosition !== undefined ? Math.round(Number.parseInt(trackProgressPosition.toString()) / 1000) : undefined, @@ -136,7 +138,7 @@ export class JRiverSource extends MemoryPositionalSource { if(data !== undefined) { const {State} = data; if(State !== PLAYER_STATE.STOPPED) { - play = [JRiverSource.formatPlayObj(data)]; + play = [JRiverSource.formatPlayObj(data, {version: this.client.version})]; } } diff --git a/src/backend/sources/KodiSource.ts b/src/backend/sources/KodiSource.ts index f4fab967..d670bff8 100644 --- a/src/backend/sources/KodiSource.ts +++ b/src/backend/sources/KodiSource.ts @@ -47,7 +47,7 @@ export class KodiSource extends MemoryPositionalSource { } } - static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { + static formatPlayObj(obj: any, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject { return KodiApiClient.formatPlayObj(obj, options); } diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index 37e822b3..bf00efc4 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -198,6 +198,7 @@ export class MPDSource extends MemoryPositionalSource { meta: { brainz, trackProgressPosition: options.trackProgressPosition, + mediaPlayerName: 'mpd' } } } diff --git a/src/backend/sources/MPRISSource.ts b/src/backend/sources/MPRISSource.ts index 84708c45..3ee7de58 100644 --- a/src/backend/sources/MPRISSource.ts +++ b/src/backend/sources/MPRISSource.ts @@ -81,7 +81,7 @@ export class MPRISSource extends MemorySource { playDate: dayjs() }, meta: { - source: 'dbus', + source: 'mpris', trackId: trackid, newFromSource, url: { @@ -286,7 +286,7 @@ const createStream = (opts: ConnectOpts): Readable&Writable => { const family = familyParams[0]; const params: any = {}; familyParams[1].split(',').map(function(p) { - let keyVal = p.split('='); + const keyVal = p.split('='); params[keyVal[0]] = keyVal[1]; }); diff --git a/src/backend/sources/MopidySource.ts b/src/backend/sources/MopidySource.ts index 70916719..7f5357f5 100644 --- a/src/backend/sources/MopidySource.ts +++ b/src/backend/sources/MopidySource.ts @@ -173,7 +173,8 @@ export class MopidySource extends MemoryPositionalSource { trackId: uri, newFromSource, trackProgressPosition: trackProgressPosition !== undefined ? Math.round(trackProgressPosition / 1000) : undefined, - deviceId: name, + mediaPlayerName: 'Mopidy' + //deviceId: name, } } } diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index d96787b9..b02b1ec6 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -18,6 +18,7 @@ export class MusicCastSource extends MemoryPositionalSource { declare config: MusicCastSourceConfig; urlData!: URLData; + version?: string; constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { @@ -57,6 +58,9 @@ export class MusicCastSource extends MemoryPositionalSource { const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString()) if (resp.body !== undefined && typeof resp.body === 'object') { const deviceInfo = resp.body as DeviceInfoResponse; + if(deviceInfo.api_version !== undefined) { + this.version = deviceInfo.api_version.toString(); + } this.logger.info(`Found ${deviceInfo.model_name} (${deviceInfo.device_id}) using API v${deviceInfo.api_version}`); } else { this.logger.warn('Could not get device info! Ignoring but probably not good...'); @@ -130,7 +134,7 @@ export class MusicCastSource extends MemoryPositionalSource { } } -const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => { +const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject => { const { play_time, @@ -152,7 +156,9 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F }, meta: { trackProgressPosition: play_time, - deviceId: 'input' in obj ? obj.input : 'cd' + deviceId: 'input' in obj ? obj.input : 'cd', + mediaPlayerName: 'MusicCast', + mediaPlayerVersion: options.version } } } \ No newline at end of file diff --git a/src/backend/sources/MusikcubeSource.ts b/src/backend/sources/MusikcubeSource.ts index b1d17f52..d00bea90 100644 --- a/src/backend/sources/MusikcubeSource.ts +++ b/src/backend/sources/MusikcubeSource.ts @@ -40,6 +40,7 @@ export class MusikcubeSource extends MemoryPositionalSource { client!: WS; deviceId: string + version?: string constructor(name: any, config: MusikcubeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { @@ -109,6 +110,7 @@ export class MusikcubeSource extends MemoryPositionalSource { this.client.addEventListener('message', (e) => { const data = getMessageData(e); if(isAuthenticateResponse(data)) { + this.version = data.options.environment.app_version; wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`); } }); @@ -197,7 +199,9 @@ export class MusikcubeSource extends MemoryPositionalSource { meta: { trackProgressPosition: playing_current_time, deviceId: this.deviceId, - trackId: external_id + trackId: external_id, + mediaPlayerName: 'Musikcube', + mediaPlayerVersion: this.version } } } diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 923efdf5..570416c5 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -339,7 +339,7 @@ export default class PlexApiSource extends MemoryPositionalSource { library, deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]), sessionId: sessionKey, - trackProgressPosition: viewOffset / 1000 + trackProgressPosition: viewOffset / 1000, } } } diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 2eccf600..1ef35205 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -197,6 +197,7 @@ export default class SpotifySource extends MemoryPositionalSource { meta: { deviceId: deviceId ?? `${NO_DEVICE}-${NO_USER}`, source: 'Spotify', + musicService: 'Spotify', trackId: id, trackProgressPosition: playbackPosition, scrobbleTsSOC, diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index 559dbbbf..167a3dac 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -16,6 +16,16 @@ import MemorySource from "./MemorySource.js"; dayjs.extend(isSameOrAfter); +interface SourceIdentifierData { + /** Subsonic Version */ + version?: string, + /** Media Player name */ + type?: string, + /** Media Player version */ + serverVersion?: string, + openSubsonic?: boolean +} + export class SubsonicSource extends MemorySource { requiresAuth = true; @@ -26,6 +36,8 @@ export class SubsonicSource extends MemorySource { usersAllow: string[] = []; + sourceData: SourceIdentifierData = {}; + constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data: { @@ -38,8 +50,16 @@ export class SubsonicSource extends MemorySource { this.canPoll = true; } - static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { - const {newFromSource = false} = options; + static formatPlayObj(obj: any, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayObject { + const { + newFromSource = false, + sourceData: { + version, + type, + serverVersion, + openSubsonic + } = {}, + } = options; const { id, title, @@ -50,6 +70,7 @@ export class SubsonicSource extends MemorySource { playerId, username, } = obj; + return { data: { artists: [artist], @@ -65,7 +86,9 @@ export class SubsonicSource extends MemorySource { trackId: id, newFromSource, user: username, - deviceId: playerId + deviceId: playerId, + mediaPlayerName: type ?? `${openSubsonic ? 'Open ' : ''}Subsonic`, + mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version } } } @@ -218,6 +241,7 @@ export class SubsonicSource extends MemorySource { const {url} = this.config.data; try { const resp = await this.callApi(request.get(`${url}/rest/ping`)); + this.sourceData = resp as SourceIdentifierData; this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); return true; } catch (e) { @@ -226,6 +250,7 @@ export class SubsonicSource extends MemorySource { if(subResponseError !== undefined) { const resp = getSubsonicResponse(subResponseError.response) this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); + this.sourceData = resp as SourceIdentifierData; return true; } @@ -262,7 +287,7 @@ export class SubsonicSource extends MemorySource { } = {} } = resp; // sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays - const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj)); + const deduped = removeDuplicates(entry.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData}))); const userFiltered = this.usersAllow.length == 0 ? deduped : deduped.filter(x => x.meta.user === undefined || this.usersAllow.map(x => x.toLocaleLowerCase()).includes(x.meta.user.toLocaleLowerCase())); return this.processRecentPlays(userFiltered); } diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 07fa7931..485586f0 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -31,6 +31,7 @@ export class VLCSource extends MemoryPositionalSource { port?: number client!: VLC.Client; deviceId: string + vlcVersion?: string; filenamePatterns: RegExp[] = []; constructor(name: any, config: VLCSourceConfig, internal: InternalConfig, emitter: EventEmitter) { @@ -110,6 +111,7 @@ export class VLCSource extends MemoryPositionalSource { try { const status = await this.client.status(); this.logger.info(`Connected successfully, found VLC ${status.version}`); + this.vlcVersion = status.version; return true; } catch (e) { let friendlyError: string | undefined; @@ -222,6 +224,9 @@ export class VLCSource extends MemoryPositionalSource { }, meta: { trackProgressPosition: time, + source: 'vlc', + mediaPlayerName: 'VLC', + mediaPlayerVersion: this.vlcVersion } } } diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index 69c15d05..5b1382ba 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -93,6 +93,9 @@ export class WebScrobblerSource extends MemorySource { } = {}): PlayObject { const { connectorLabel, + connector: { + label: connectorL + } = {}, controllerTabId = 'UNK', processed, parsed: { @@ -137,6 +140,7 @@ export class WebScrobblerSource extends MemorySource { origin: originUrl }, deviceId: `${connectorLabel}-${controllerTabId}`, + musicService: connectorL, scrobbleAllowed: isScrobblingAllowed, nowPlaying: options.nowPlaying ?? false } diff --git a/src/backend/sources/YTMusicSource.ts b/src/backend/sources/YTMusicSource.ts index 78031c36..b32a53ef 100644 --- a/src/backend/sources/YTMusicSource.ts +++ b/src/backend/sources/YTMusicSource.ts @@ -395,6 +395,7 @@ Redirect URI : ${this.redirectUri}`); }, meta: { source: 'YTMusic', + musicService: 'Youtube Music', trackId: id, newFromSource, comment: shelf diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index a7bbf988..cc998ccc 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -111,6 +111,11 @@ export interface PlayData extends TrackData { export interface PlayMeta { source?: string + /* + * If applicable, the name of the Service providing the track (Spotify, Tidal, etc...) + */ + musicService?: string + /** * Specifies from what facet/data from the source this play was parsed from IE history, now playing, etc... * */ @@ -159,6 +164,16 @@ export interface PlayMeta { * The position the "player" is at in the track at the time the play was reported, in seconds * */ trackProgressPosition?: number + + /* + * Name of the media player (program) + */ + mediaPlayerName?: string + + /* + * Version of the media player (program) + */ + mediaPlayerVersion?: string /** * A unique identifier for the device playing this track * */