Skip to content

Commit

Permalink
feat(sources): Improve Source of Truth usage and presentation to user #…
Browse files Browse the repository at this point in the history
…134

* Refactor SOT to be a type (may have more in the future)
* Set player stale/orphan interval based on SOT type
* Add note in log when initializing MemorySource which does not use player as SOT
* Add tooltip to UI player when it is not SOT
  • Loading branch information
FoxxMD committed Jan 31, 2024
1 parent ecde657 commit 155ec14
Show file tree
Hide file tree
Showing 12 changed files with 99 additions and 23 deletions.
3 changes: 3 additions & 0 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {SimpleIntervalJob, ToadScheduler} from "toad-scheduler";
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
import {ErrorWithCause} from "pony-cause";
import LastfmSource from "./sources/LastfmSource.js";


dayjs.extend(utc)
Expand Down Expand Up @@ -136,6 +137,8 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
if (anyNotReady) {
logger.info(`Some sources are not ready, open the dashboard to continue`);
}
const lastfm = scrobbleSources.getByName('mylfm') as LastfmSource;
await lastfm.api.updateNowPlaying({data: {artists: ['Silva', 'Marina Sena', 'RDD'], 'track': 'Te Vi Na Rua'}, meta: {}});

scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
Expand Down
5 changes: 3 additions & 2 deletions src/backend/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
LogInfoJson,
LogLevel,
LogOutputConfig,
PlayObject,
PlayObject, SOURCE_SOT,
SourceStatusData,
} from "../../core/Atomic.js";
import {Logger} from "@foxxmd/winston";
Expand Down Expand Up @@ -201,7 +201,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
hasAuth: requiresAuth,
hasAuthInteraction: requiresAuthInteraction,
authed,
players: 'players' in x ? (x as MemorySource).playersToObject() : {}
players: 'players' in x ? (x as MemorySource).playersToObject() : {},
sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY
};
if(!x.isReady()) {
if(x.buildOK === false) {
Expand Down
5 changes: 3 additions & 2 deletions src/backend/sources/LastfmSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sortByOldestPlayDate } from "../utils.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import {TrackObject, UserGetRecentTracksResponse} from "lastfm-node-client";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import {PlayObject, SOURCE_SOT} from "../../core/Atomic.js";
import MemorySource from "./MemorySource.js";
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
import dayjs from "dayjs";
Expand Down Expand Up @@ -32,7 +32,8 @@ export default class LastfmSource extends MemorySource {
this.canPoll = true;
this.canBacklog = true;
this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir, localUrl: internal.localUrl});
this.playerSourceOfTruth = false;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
}

static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
Expand Down
4 changes: 3 additions & 1 deletion src/backend/sources/ListenbrainzSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import MemorySource from "./MemorySource.js";
import {ErrorWithCause} from "pony-cause";
import request from "superagent";
import {isNodeNetworkException} from "../common/errors/NodeErrors.js";
import {SOURCE_SOT} from "../../core/Atomic.js";

export default class ListenbrainzSource extends MemorySource {

Expand All @@ -28,7 +29,8 @@ export default class ListenbrainzSource extends MemorySource {
this.canPoll = true;
this.canBacklog = true;
this.api = new ListenbrainzApiClient(name, config.data);
this.playerSourceOfTruth = false;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
}

static formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
Expand Down
18 changes: 9 additions & 9 deletions src/backend/sources/MemorySource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,21 @@ import {
SourceType,
} from "../common/infrastructure/Atomic.js";
import TupleMap from "../common/TupleMap.js";
import { AbstractPlayerState, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import {AbstractPlayerState, createPlayerOptions, PlayerStateOptions} from "./PlayerState/AbstractPlayerState.js";
import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js";
import {Logger} from "@foxxmd/winston";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import {PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj} from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import {SimpleIntervalJob, Task, ToadScheduler} from "toad-scheduler";
import { SourceConfig } from "../common/infrastructure/config/source/sources.js";
import {EventEmitter} from "events";
import objectHash from 'object-hash';
import { timePassesScrobbleThreshold } from "../utils/TimeUtils.js";
import {PollingOptions} from "../common/infrastructure/config/common.js";

export default class MemorySource extends AbstractSource {

playerSourceOfTruth: boolean = true;
playerSourceOfTruth: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER;

/*
* MemorySource uses its own state to maintain a list of recently played tracks and determine if a track is valid.
Expand Down Expand Up @@ -102,8 +103,7 @@ export default class MemorySource extends AbstractSource {

setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => {
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
staleInterval: (this.config.data.interval ?? 30) * 3,
orphanedInterval: (this.config.data.maxInterval ?? 60) * 5,
...createPlayerOptions(this.config.data as Partial<PollingOptions>),
...opts
}));
this.playerState.set(idStr, '');
Expand Down Expand Up @@ -179,7 +179,7 @@ export default class MemorySource extends AbstractSource {
if (thresholdResults.passes) {
const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
if (matchingRecent === undefined) {
if(this.playerSourceOfTruth) {
if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
}
newStatefulPlays.push(candidate);
Expand All @@ -189,7 +189,7 @@ export default class MemorySource extends AbstractSource {
if (!playDate.isSame(rplayDate)) {
if (duration !== undefined) {
if (playDate.isAfter(rplayDate.add(duration, 's'))) {
if(this.playerSourceOfTruth) {
if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
}
newStatefulPlays.push(candidate);
Expand All @@ -198,15 +198,15 @@ export default class MemorySource extends AbstractSource {
const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(genGroupId(candidate));
if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) {
// if most recent stateful play is not this track we'll add it
if(this.playerSourceOfTruth) {
if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
}
newStatefulPlays.push(candidate);
}
}
}
}
} else if(playChanged) {
} else if(playChanged && this.playerSourceOfTruth === SOURCE_SOT.PLAYER) {
player.logger.verbose(`${stPrefix} not added because ${thresholdResultSummary(thresholdResults)}.`);
}
}
Expand Down
28 changes: 25 additions & 3 deletions src/backend/sources/PlayerState/AbstractPlayerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import dayjs, {Dayjs} from "dayjs";
import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js";
import {Logger} from "@foxxmd/winston";
import { ListenProgress } from "./ListenProgress.js";
import { PlayObject, Second, SourcePlayerObj } from "../../../core/Atomic.js";
import {PlayObject, Second, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj} from "../../../core/Atomic.js";
import { buildTrackString } from "../../../core/StringUtils.js";
import { ListenRange } from "./ListenRange.js";
import {id} from "common-tags";
import {PollingOptions} from "../../common/infrastructure/config/common.js";

export interface PlayerStateIntervals {
staleInterval?: number
Expand All @@ -22,6 +23,27 @@ export interface PlayerStateIntervals {
export interface PlayerStateOptions extends PlayerStateIntervals {
}

export const DefaultPlayerStateOptions: PlayerStateOptions = {};

export const createPlayerOptions = (pollingOpts?: Partial<PollingOptions>, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER): PlayerStateOptions => {
const {
interval = 30,
maxInterval = 60,
} = pollingOpts || {};
if(sot === SOURCE_SOT.PLAYER) {
return {
staleInterval: interval * 3,
orphanedInterval: interval * 5
}
}
// if this player is not the source of truth we don't care about waiting around to see if the state comes back
// in fact, we probably want to get rid of it as fast as possible since its superficial and more of an ephemeral "Now Playing" status than something we are actually tracking
return {
staleInterval: interval,
orphanedInterval: maxInterval
}
}

export abstract class AbstractPlayerState {
logger: Logger;
reportedStatus: ReportedPlayerStatus = REPORTED_PLAYER_STATUSES.unknown
Expand All @@ -36,14 +58,14 @@ export abstract class AbstractPlayerState {
createdAt: Dayjs = dayjs();
stateLastUpdatedAt: Dayjs = dayjs();

protected constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
protected constructor(logger: Logger, platformId: PlayPlatformId, opts: PlayerStateOptions = DefaultPlayerStateOptions) {
this.platformId = platformId;
this.logger = logger.child({labels: [`Player ${this.platformIdStr}`]});

const {
staleInterval = 120,
orphanedInterval = 300,
} = opts || {};
} = opts;
this.stateIntervalOptions = {staleInterval, orphanedInterval: orphanedInterval};
}

Expand Down
5 changes: 3 additions & 2 deletions src/backend/sources/WebScrobblerSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
ReportedPlayerStatus,
} from "../common/infrastructure/Atomic.js";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import {PlayObject, SOURCE_SOT} from "../../core/Atomic.js";
import { WebScrobblerHookEvent, WebScrobblerPayload, WebScrobblerSong } from "../common/vendor/webscrobbler/interfaces.js";
import dayjs from "dayjs";
import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js";
Expand All @@ -20,7 +20,8 @@ export class WebScrobblerSource extends MemorySource {
constructor(name: any, config: WebScrobblerSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('webscrobbler', name, config, internal, emitter);
this.multiPlatform = true;
this.playerSourceOfTruth = false;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)

const {
data = {},
Expand Down
18 changes: 18 additions & 0 deletions src/client/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {PropsWithChildren, ReactElement} from "react";

export interface TooltipProps {
message: string | ReactElement
}

const Tooltip = (props: PropsWithChildren<TooltipProps>) => {
const {children, message} = props;
return (
<div className="group relative flex">
{children}
<span
className="absolute top-5 scale-0 transition-all rounded bg-gray-800 p-2 text-xs text-white group-hover:scale-100">{message}</span>
</div>
)
}

export default Tooltip;
10 changes: 8 additions & 2 deletions src/client/components/player/Player.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import React, {useState, useCallback, Fragment} from 'react';
import './player.scss';
import PlayerTimestamp from "./PlayerTimestamp";
import {SourcePlayerJson} from "../../../core/Atomic";
import {SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerJson} from "../../../core/Atomic";
import PlayerInfo from "./PlayerInfo";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faBars, faTimes} from '@fortawesome/free-solid-svg-icons'
import {faBars, faTimes, faQuestionCircle} from '@fortawesome/free-solid-svg-icons'

import {capitalize} from "../../../core/StringUtils";
import Tooltip from "../Tooltip";

export interface PlayerProps {
data: SourcePlayerJson
sot?: SOURCE_SOT_TYPES
}

export interface Track {
Expand All @@ -24,6 +26,7 @@ export interface Track {
const Player = (props: PlayerProps) => {
const {
data,
sot = SOURCE_SOT.PLAYER
} = props;

const {
Expand Down Expand Up @@ -71,6 +74,9 @@ const Player = (props: PlayerProps) => {
return (
<article className={["player", "mb-2"].join(' ')}>
<div className="player__wrapper">
{sot === SOURCE_SOT.HISTORY ? <span className="player-tooltip"><Tooltip message="This player is for DISPLAY ONLY and likely represents a 'Now Playing' status exposed by the Source. For scrobbling Multi Scrobbler uses the 'recently played' or 'history' information provided by this source.">
<FontAwesomeIcon color="black" icon={faQuestionCircle}/>
</Tooltip></span> : null}
<button className="button toggle-playlist" onClick={toggleViewMode}>
<FontAwesomeIcon color="black" icon={viewMode === 'playlist' ? faTimes : faBars}/>
</button>
Expand Down
14 changes: 14 additions & 0 deletions src/client/components/player/player.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ $primary: #00ACC1;
position: relative;
}

.player-tooltip {
width: 90%;
position: absolute;
top: $spacing;
left: $spacing/0.5;
border: 0;
outline: 0;
background: transparent;
text-shadow: rgba(black, 0.75) 0 0 5px;
box-shadow: none !important;
color: white !important;
z-index: 1;
}

.toggle-playlist {
position: absolute;
top: $spacing/2;
Expand Down
5 changes: 3 additions & 2 deletions src/client/components/statusCard/SourceStatusCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
tracksDiscovered,
hasAuthInteraction,
type,
players = {}
players = {},
sot
} = data;
if(type === 'listenbrainz' || type === 'lastfm') {
header = `${display} (Source)`;
Expand All @@ -70,7 +71,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => {

// TODO links
body = (<div className="statusCardBody">
{platformIds.map(x => <Player key={x} data={players[x]}/>)}
{platformIds.map(x => <Player key={x} data={players[x]} sot={sot}/>)}
<div>{discovered}: {tracksDiscovered}</div>
{canPoll && hasAuthInteraction ? <a target="_blank" href={`/api/source/auth?name=${name}&type=${type}`}>(Re)authenticate</a> : null}
</div>);
Expand Down
7 changes: 7 additions & 0 deletions src/core/Atomic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface SourceStatusData {
hasAuthInteraction: boolean;
authed: boolean;
players: Record<string, SourcePlayerJson>
sot: SOURCE_SOT_TYPES
}

export interface ClientStatusData {
Expand Down Expand Up @@ -235,3 +236,9 @@ export interface TemporalPlayComparison {
}
range?: false | ListenRangeData
}

export type SOURCE_SOT_TYPES = 'player' | 'history';
export const SOURCE_SOT = {
PLAYER : 'player' as SOURCE_SOT_TYPES,
HISTORY: 'history' as SOURCE_SOT_TYPES
}

0 comments on commit 155ec14

Please sign in to comment.