···88import { joinedUrl } from "../../../utils/NetworkUtils.js";
99import { hasNodeNetworkException } from "../../errors/NodeErrors.js";
1010import { sleep } from "../../../utils.js";
1111+import { RequestRetryOptions } from "../../infrastructure/config/common.js";
1212+import { RetryContext } from "p-retry";
1313+import { NO_RETRY_HTTP_STATUS, tryApiCall } from "../../../utils/RequestUtils.js";
11141215export type ThumbSize = 250 | 500 | 1200;
1316const THUMB_SIZES = [250, 500, 1200];
···4144 images: CoverArtReleaseImage[]
4245}
43464444-export interface CoverArtApiConfig {
4747+export interface CoverArtApiConfig extends RequestRetryOptions {
4548 url?: URL;
4649}
4750···8386 return cachedArt;
8487 } else {
8588 let result = undefined,
8686- retries = 0,
8789 err: Error;
88908991 const url = joinedUrl(this.baseUrl, `/release/${mbid}/${thumbParams}`);
90929191- while(result === undefined && retries < 2) {
9292- try {
9393- const resp = await this.coverThumbRequest(url.toString());
9494- if(resp === undefined) {
9595- result = false;
9696- } else {
9797- result = resp;
9898- }
9999- err = undefined;
100100- } catch (e) {
101101- err = e;
102102- if(hasNodeNetworkException(e)) {
103103- this.logger.warn(`Request to ${url.toString()} failed but retries (${retries}) are not greater than max (1), retrying request after a short break... Error Message: ${e.message}`);
104104- retries++;
105105- await sleep(500);
106106- continue;
107107- } else {
108108- break;
109109- }
9393+ try {
9494+ const resp = await this.coverThumbRequest(url.toString());
9595+ if(resp === undefined) {
9696+ result = false;
9797+ } else {
9898+ result = resp;
11099 }
100100+ err = undefined;
101101+ } catch (e) {
102102+ err = e;
111103 }
112104113105 if(result === false) {
···127119 protected coverThumbRequest = async (url: string): Promise<string | undefined> => {
128120 try {
129121 // https://musicbrainz.org/doc/Cover_Art_Archive/API#/release/{mbid}/({id}|front|back)-(250|500|1200)
130130- const resp = await request
122122+ const resp = await tryApiCall(() => request
131123 .get(url)
132124 // only follow first redirect so we get the url without actually downloading the image
133133- .redirects(1);
125125+ .redirects(1), {
126126+ ...this.config,
127127+ logFailure: logUnexpectedStatus,
128128+ noRetryStatus: [...NO_RETRY_HTTP_STATUS, 404, 302, 307]
129129+ });
134130 throw new Error('Should not be getting this far');
135131 } catch (e) {
136132 if (isSuperAgentResponseError(e)) {
···173169 }
174170 }
175171 }
172172+}
173173+174174+const logUnexpectedStatus = (context: RetryContext): boolean => {
175175+ return !isSuperAgentResponseError(context.error) || ![404,302,307].includes(context.error.status);
176176}
+2
src/backend/index.ts
···66import relativeTime from 'dayjs/plugin/relativeTime.js';
77import isToday from 'dayjs/plugin/isToday.js';
88import timezone from 'dayjs/plugin/timezone.js';
99+import week from 'dayjs/plugin/weekOfYear.js';
910import utc from 'dayjs/plugin/utc.js';
1011import * as path from "path";
1112import { SimpleIntervalJob, ToadScheduler } from "toad-scheduler";
···2930dayjs.extend(duration);
3031dayjs.extend(timezone);
3132dayjs.extend(isToday);
3333+dayjs.extend(week);
32343335// eslint-disable-next-line prefer-arrow-functions/prefer-arrow-functions
3436(async function () {
+212-208
src/backend/scrobblers/AbstractScrobbleClient.ts
···99 NowPlayingUpdateThreshold,
1010 PlayObject,
1111 PlayObjectLifecycleless,
1212- QueuedScrobble, ScrobbleActionResult, PlayMatchResult, ScrobblePayload, ScrobbleResponse, SourcePlayerObj, TA_DURING,
1212+ QueuedScrobble, ScrobbleActionResult, PlayMatchResult, SourcePlayerObj, TA_DURING,
1313 TA_FUZZY,
1414- TrackStringOptions
1414+ TrackStringOptions,
1515+ TA_EXACT,
1616+ SOURCE_SOT
1517} from "../../core/Atomic.js";
1618import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
1719import AbstractComponent from "../common/AbstractComponent.js";
1818-import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
2020+import { hasUpstreamError } from "../common/errors/UpstreamError.js";
1921import {
2022 ARTIST_WEIGHT,
2123 Authenticatable,
···2426 DEFAULT_RETRY_MULTIPLIER,
2527 DUP_SCORE_THRESHOLD,
2628 FormatPlayObjectOptions,
2727- PlayPlatformId,
2929+ PaginatedTimeRangeOptions,
3030+ REFRESH_STALE_DEFAULT,
2831 ScrobbledPlayObject,
2932 SourceIdentifier,
3033 TIME_WEIGHT,
3434+ TimeRangeListensFetcher,
3135 TITLE_WEIGHT,
3236} from "../common/infrastructure/Atomic.js";
3337import { CommonClientConfig, NowPlayingOptions, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js";
···3539import { Notifiers } from "../notifier/Notifiers.js";
3640import {
3741 comparingMultipleArtists,
3838- genGroupId,
3939- genGroupIdStr,
4040- genGroupIdStrFromPlay,
4142 isDebugMode,
4243 parseBool,
4344 playObjDataMatch,
4445 pollingBackoff,
4545- setIntersection,
4646 sleep,
4747 sortByOldestPlayDate,
4848} from "../utils.js";
···5252 hasAcceptableTemporalAccuracy,
5353 temporalAccuracyToString,
5454 temporalPlayComparisonSummary,
5555+ todayAwareFormat,
5556} from "../utils/TimeUtils.js";
5657import { WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
5758import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
5858-import { MSCache } from "../common/Cache.js";
5959import { getRoot } from "../ioc.js";
6060import { rehydratePlay } from "../utils/CacheUtils.js";
6161import { findAsyncSequential, staggerMapper } from "../utils/AsyncUtils.js";
···6363import { comparePlayArtistsNormalized, comparePlayTracksNormalized, lifecyclelessInvariantTransform } from "../utils/PlayComparisonUtils.js";
6464import { normalizeStr } from "../utils/StringUtils.js";
6565import prom, { Counter, Gauge } from 'prom-client';
6666-import { ScrobbleSubmitError } from "../common/errors/MSErrors.js";
6666+import { ScrobbleSubmitError, SimpleError } from "../common/errors/MSErrors.js";
6767import {serializeError} from 'serialize-error';
6868-import { redactString } from "@foxxmd/redact-string";
6969-import clone from "clone";
6868+import { DEFAULT_NEW_PADDING, groupPlaysToTimeRanges } from "../utils/ListenFetchUtils.js";
70697170type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>;
7271type NowPlayingQueue = Map<string, PlatformMappedPlays>;
···8382 protected MAX_STORED_SCROBBLES = 40;
8483 protected MAX_INITIAL_SCROBBLES_FETCH = this.MAX_STORED_SCROBBLES;
85848686- #recentScrobblesList: PlayObject[] = [];
8585+ scrobbleSOTRanges: PaginatedTimeRangeOptions[] = [];
8786 scrobbledPlayObjs: FixedSizeList<ScrobbledPlayObject>;
8888- lastScrobbledPlayDate?: Dayjs;
8989- newestScrobbleTime?: Dayjs
9090- oldestScrobbleTime?: Dayjs
9187 tracksScrobbled: number = 0;
92889393- lastScrobbleCheck: Dayjs = dayjs(0)
9489 lastScrobbleAttempt: Dayjs = dayjs(0)
9590 upstreamRefresh: MarkOptional<Required<UpstreamRefreshOptions>, 'refreshInitialCount'>;
9691 checkExistingScrobbles: boolean;
···142137 refreshEnabled = true,
143138 refreshInitialCount,
144139 refreshMinInterval = 5,
145145- refreshStaleAfter = 60,
140140+ refreshStaleAfter = REFRESH_STALE_DEFAULT,
146141 checkExistingScrobbles = true,
147142 verbose = {},
148143 } = {},
···183178 this.queuedGauge = clientMetrics.queued;
184179 this.deadLetterGauge = clientMetrics.deadLetter;
185180 this.scrobbledCounter = clientMetrics.scrobbled;
186186- }
187187-188188- set recentScrobbles(scrobbles: PlayObject[]) {
189189- const sorted = [...scrobbles];
190190- sorted.sort(sortByOldestPlayDate);
191191- this.#recentScrobblesList = sorted;
192192- }
193193-194194- get recentScrobbles() {
195195- return this.#recentScrobblesList;
196181 }
197182198183 protected getIdentifier() {
···359344 this.initializeNowPlaying();
360345361346 let initialLimit = refreshInitialCount;
362362- if(refreshInitialCount > this.MAX_INITIAL_SCROBBLES_FETCH) {
347347+ if (refreshInitialCount > this.MAX_INITIAL_SCROBBLES_FETCH) {
363348 this.logger.warn(`Defined initial scrobbles count (${refreshInitialCount}) higher than maximum allowed (${this.MAX_INITIAL_SCROBBLES_FETCH}). Will use max instead.`);
364349 initialLimit = this.MAX_INITIAL_SCROBBLES_FETCH;
365350 }
366351367367- this.logger.verbose(`Fetching up to ${initialLimit} initial scrobbles...`);
368368- await this.refreshScrobbles(initialLimit);
369369- this.lastScrobbledPlayDate = this.newestScrobbleTime;
370370- }
352352+ this.logger.verbose(`Preloading up to ${initialLimit} initial scrobbles...`);
371353372372- refreshScrobbles = async (limit: number = this.MAX_STORED_SCROBBLES) => {
373373- if (this.upstreamRefresh.refreshEnabled) {
374374- this.logger.debug('Refreshing recent scrobbles');
375375- const recent = await this.getScrobblesForRefresh(limit);
376376- this.logger.debug(`Found ${recent.length} recent scrobbles`);
377377- this.recentScrobbles = recent;
378378- if (this.recentScrobbles.length > 0) {
379379- const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
380380- const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
381381- this.newestScrobbleTime = newestScrobbleTime;
382382- this.oldestScrobbleTime = oldestScrobbleTime;
383383-384384- this.filterScrobbledTracks();
354354+ try {
355355+ const preload = await this.getScrobblesForTimeRange({
356356+ limit: initialLimit,
357357+ fetchMax: initialLimit
358358+ });
359359+ if(preload === undefined) {
360360+ this.logger.warn('Preload result was undefined!');
361361+ } else {
362362+ if(preload.length === 0) {
363363+ this.logger.verbose(`Preloaded 0 scrobbles.`);
364364+ } else {
365365+ preload.sort(sortByOldestPlayDate);
366366+ const from = preload[0].data.playDate;
367367+ // we are assuming that all fetchers return latest scrobbles first (pretty sure this is the case)
368368+ const to = dayjs();// preload[preload.length - 1].data.playDate;
369369+ await this.cache.cacheClientScrobbles.set<PlayObject[]>(this.getScrobbleCacheKey(from, to), preload, '60s');
370370+ this.scrobbleSOTRanges.push({from: from.unix(), to: to.unix()});
371371+ this.logger.verbose(`Preloaded ${preload.length} scrobbles from ${todayAwareFormat(from)} to ${todayAwareFormat(to)}`);
372372+ }
385373 }
374374+ } catch (e) {
375375+ this.logger.warn(new SimpleError('Could not preload scrobbles', {cause: e, shortStack: true}));
386376 }
387387- this.lastScrobbleCheck = dayjs();
388377 }
389378390390- protected abstract getScrobblesForRefresh(limit: number): Promise<PlayObject[]>;
379379+ abstract getScrobblesForTimeRange: TimeRangeListensFetcher;
391380392392- shouldRefreshScrobble = () => {
393393- const {
394394- refreshStaleAfter,
395395- refreshMinInterval,
396396- refreshEnabled
397397- } = this.upstreamRefresh;
381381+ protected getScrobbleCacheKey = (from: Dayjs | number, to: Dayjs | number): string => {
382382+ return `${this.name}-scrobbleRange-${typeof from === 'number' ? from : from.unix()}-${typeof to === 'number' ? to :to.unix()}`;
383383+ }
398384399399- if (!refreshEnabled) {
400400- this.logger.debug({labels: ['Upstream Refresh']}, `Should NOT refresh => refreshEnabled is false`);
401401- return false;
402402- }
385385+ handleQueuedScrobbleRanges = () => {
386386+ this.scrobbleSOTRanges = groupPlaysToTimeRanges(this.queuedScrobbles.map(x => x.play).concat(this.deadLetterScrobbles.map(x => x.play)), this.scrobbleSOTRanges, {staleNowBuffer: this.config.options?.refreshStaleAfter});
387387+ }
403388404404- if(this.queuedScrobbles.length === 0) {
405405- this.logger.debug({labels: ['Upstream Refresh']}, `Should NOT refresh => no scrobbles in queue!`);
406406- return false;
389389+ getSOTScrobblesForPlay = async (play: PlayObject): Promise<PlayObject[]> => {
390390+ let range: PaginatedTimeRangeOptions = this.scrobbleSOTRanges.find(x => x.from <= play.data.playDate.unix() && x.to > Math.min(dayjs().subtract(this.config.options?.refreshStaleAfter ?? REFRESH_STALE_DEFAULT, 's').unix(), play.data.playDate.unix()));
391391+ if(range === undefined) {
392392+ this.logger.warn(`No Scrobble SOT range found! Should have been handled before this. Creating a new one for ${buildTrackString(play)}`);
393393+ range = {
394394+ from: play.data.playDate.subtract(DEFAULT_NEW_PADDING).unix(),
395395+ to: Math.min(play.data.playDate.add(DEFAULT_NEW_PADDING).unix(), dayjs().subtract(this.config.options?.refreshStaleAfter ?? REFRESH_STALE_DEFAULT, 's').unix())
396396+ };
397397+ this.scrobbleSOTRanges.push(range);
407398 }
408408-409409- const queuedPlayedDate = this.getLatestQueuePlayDate();
410410-411411- // if newest queued play was played more recently than the last time we refreshed upstream scrobbles
412412- if (this.scrobblesLastCheckedAt().unix() < queuedPlayedDate.unix()) {
413413- if(!this.scrobblesRefreshMinIntervalPassed()) {
414414- this.logger.debug({labels: ['Upstream Refresh']}, `Should refresh but WILL NOT => queued scrobble playDate is newer than last refresh but refreshMinInterval (${refreshMinInterval}ms) has not passed since last check`);
415415- return false;
416416- } else {
417417- this.logger.debug({labels: ['Upstream Refresh']}, 'Should refresh => newest queued scrobble playDate is newer than last refresh');
418418- return true;
419419- }
399399+ const cachedPlaysRes = await this.cache.cacheClientScrobbles.get<PlayObject[] | Error>(this.getScrobbleCacheKey(range.from, range.to));
400400+ if(cachedPlaysRes instanceof Error) {
401401+ throw new SimpleError('Cannot get historical plays due to cached error', {cause: cachedPlaysRes, shortStack: true});
420402 }
421421-422422- // if the play date of the last Play scrobbled is *newer*
423423- // than the queued scrobble we are about to scrobble
424424- // then we are inserting a scrobble out of order which can happen if
425425- // * backlogging and upstream returned plays out of order
426426- // * processing dead letter queue
427427- // * two sources have different history
428428- //
429429- // in all cases we probably want to refresh
430430- if(this.lastScrobbledPlayDate !== undefined && this.queuedScrobbles[0].play.meta.newFromSource && this.lastScrobbledPlayDate.unix() > this.queuedScrobbles[0].play.data.playDate.unix()) {
431431- if(!this.scrobblesRefreshMinIntervalPassed()) {
432432- this.logger.debug({labels: ['Upstream Refresh']}, `Should refresh but WILL NOT => queued scrobble playDate is older than last scrobbled play (out-of-order insert) but refreshMinInterval (${refreshMinInterval}ms) has not passed since last check`);
433433- return false;
434434- } else {
435435- this.logger.debug({labels: ['Upstream Refresh']}, 'Should refresh => queued scrobble playDate is older than last scrobbled play (out-of-order insert)');
436436- return true;
437437- }
403403+ if(cachedPlaysRes !== undefined) {
404404+ return cachedPlaysRes;
438405 }
439439-440440- // if it's been X seconds since we last refreshed
441441- if(refreshStaleAfter !== undefined) {
442442- const diff = dayjs().diff(this.scrobblesLastCheckedAt(), 's');
443443- if(diff > refreshStaleAfter) {
444444- this.logger.debug({labels: ['Upstream Refresh']}, `Should refresh => last refresh (${diff}s ago) was longer than refreshStaleAfter (${refreshStaleAfter}s)`);
445445- return true;
446446- }
406406+ try {
407407+ const plays = await this.getScrobblesForTimeRange(range);
408408+ plays.sort(sortByOldestPlayDate);
409409+ await this.cache.cacheClientScrobbles.set<PlayObject[] | Error>(this.getScrobbleCacheKey(range.from, range.to), plays, (this.config.options?.refreshStaleAfter ?? REFRESH_STALE_DEFAULT) * 1000);
410410+ return plays;
411411+ } catch (e) {
412412+ await this.cache.cacheClientScrobbles.set<PlayObject[] | Error>(this.getScrobbleCacheKey(range.from, range.to), e, '10s');
413413+ throw new SimpleError('Cannot get historical plays', {cause: e, shortStack: true});
447414 }
448448-449449- return false;
450415 }
451451-452452- protected scrobblesLastCheckedAt = () => this.lastScrobbleCheck
453453- protected scrobblesLastCheckedAtDiff = () => dayjs().diff(this.scrobblesLastCheckedAt(), 'ms')
454454- protected scrobblesRefreshMinIntervalPassed = () => {
455455- const {
456456- refreshMinInterval,
457457- } = this.upstreamRefresh;
458458- return this.scrobblesLastCheckedAtDiff() >= refreshMinInterval;
459459- }
460460-461416 public async alreadyScrobbled(playObj: PlayObject, log?: boolean): Promise<[boolean, PlayMatchResult]> {
462462- const result = await this.existingScrobble(playObj);
417417+ const result = await this.existingScrobble(playObj, await this.getSOTScrobblesForPlay(playObj));
463418 return [result.match, result];
464419 }
465420···468423 return obj;
469424 }
470425471471- // time frame is valid as long as the play date for the source track is newer than the oldest play time from the scrobble client
472472- // ...this is assuming the scrobble client is returning "most recent" scrobbles
473473- timeFrameIsValid = (playObj: PlayObject) => {
474474-475475- if(this.oldestScrobbleTime === undefined) {
476476- return [true, ''];
477477- }
478478-479479- const {
480480- data: {
481481- playDate,
482482- } = {},
483483- } = playObj;
484484- const validTime = playDate.isAfter(this.oldestScrobbleTime);
485485- let log = '';
486486- if (!validTime) {
487487- const dur = dayjs.duration(Math.abs(playDate.diff(this.oldestScrobbleTime))).humanize(false);
488488- log = `occurred ${dur} before the oldest scrobble returned by this client (${this.oldestScrobbleTime.format()})`;
489489- }
490490- return [validTime, log]
491491- }
492492-493426 addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObjectLifecycleless) => {
494427 this.scrobbledPlayObjs.add({play: playObj, scrobble: scrobbledPlay});
495428 this.scrobbledCounter.labels(this.getPrometheusLabels()).inc();
496496- this.lastScrobbledPlayDate = playObj.data.playDate;
429429+ //this.lastScrobbledPlayDate = playObj.data.playDate;
497430 this.tracksScrobbled++;
498498- }
499499-500500- filterScrobbledTracks = () => {
501501- this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ;
502431 }
503432504433 getScrobbledPlays = () => this.scrobbledPlayObjs.data.map(x => x.play)
···523452 return [matchPlayDate, dtInvariantMatches];
524453 }
525454526526- existingScrobble = async (playObjPre: PlayObject): Promise<PlayMatchResult> => {
455455+ existingScrobble = async (playObjPre: PlayObject, existingScrobbles: PlayObject[], log: boolean = true): Promise<PlayMatchResult> => {
527456528457 const result: PlayMatchResult = {
529458 match: false,
···568497569498 // if no recent scrobbles found then assume we haven't submitted it
570499 // (either user doesnt want to check history or there is no history to check!)
571571- if (this.recentScrobbles.length === 0) {
572572- this.dupeLogger.trace(`${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
500500+ if (existingScrobbles.length === 0) {
501501+ this.dupeLogger.trace(`${buildTrackString(playObj, scoreTrackOpts)} => No Match because no existing scrobbles returned from API`);
573502 result.reason = 'no recent scrobbles returned from API';
574503 return result;
575504 }
576505577577- // we have found an existing submission but without an exact date
578578- // in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
579579- //const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
580580-581506 // only check for fuzzy if we know this play is NOT a repeat
582507 // otherwise we may get a false positive on the previously played track ending time == repeat start time
583508 // -- this is info we only know if play was generated from MS player so we can be reasonably sure
584584- const looseTimeAccuracy = playObj.data.repeat ? [TA_DURING] : [TA_FUZZY, TA_DURING];
509509+ //
510510+ // OR if play was generated from a source that uses History (endpoint sources, lfm or lz history sources)
511511+ // then we can be reasonably sure that our candidate play has an accurate timestamp and wouldn't fuzzy match a previous scrobble
512512+ const looseTimeAccuracy = playObj.data.repeat || playObj.meta.sourceSOT === SOURCE_SOT.HISTORY ? [TA_DURING] : [TA_FUZZY, TA_DURING];
585513586514587587- existingScrobble = await findAsyncSequential(this.recentScrobbles, async (xPre) => {
515515+ existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => {
588516589517 const x = await this.transformPlay(xPre, TRANSFORM_HOOK.existing);
590518···651579 result.match = score >= DUP_SCORE_THRESHOLD;
652580 result.breakdowns = scoreBreakdowns;
653581 result.score = score;
654654- //closestMatch = scoreInfo
582582+583583+ if(result.match === false && temporalComparison.match === TA_EXACT && score >= 0.90) {
584584+ // if we have a score >= 90 and time is an exact match
585585+ // it's likely the differences are due to source-scrobbler data presentation, or deficiencies,
586586+ // rather than actually being unique
587587+ // so force match in this instance
588588+ result.match = true;
589589+ result.reason = `Score ${score.toFixed(2)} is not greater than threshold (${DUP_SCORE_THRESHOLD}) but it is very close and timestamp is an exact match, vibe matching.`;
590590+ }
655591 }
656592657593 return score >= DUP_SCORE_THRESHOLD;
···663599 closestScrobbleParts.push(`Closest Scrobble: ${buildTrackString(result.closestMatchedPlay, scoreTrackOpts)}`);
664600 }
665601 closestScrobbleParts.push(result.reason);
666666- let summary = `${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`;
667667- this.dupeLogger.trace(`${summary}${result.breakdowns.length > 0 ? `\n${result.breakdowns.join('\n')}` : ''}`);
668668-602602+ let summaryStart = `${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`;
603603+ const summary = `${summaryStart}${result.breakdowns.length > 0 ? `\n${result.breakdowns.join('\n')}` : ''}`
604604+ result.summary = summary;
605605+ if(log) {
606606+ this.dupeLogger.trace(summary);
607607+ }
669608 return result;
670609 }
671610···809748810749 try {
811750 this.scrobbling = true;
751751+ if(!this.upstreamRefresh.refreshEnabled) {
752752+ this.logger.verbose('Scrobble refresh is DISABLED. All queued scrobbles will likely always be scrobbled (nothing to check duplicates against).');
753753+ }
812754 while (!this.shouldStopScrobbleProcessing()) {
755755+ let queueEmpty = this.queuedScrobbles.length === 0;
813756 while (this.queuedScrobbles.length > 0) {
814814- if (this.shouldRefreshScrobble()) {
815815- await this.refreshScrobbles();
757757+ this.handleQueuedScrobbleRanges();
758758+ if(!this.upstreamRefresh.refreshEnabled) {
759759+ this.logger.trace('Scrobble refresh is DISABLED.');
816760 }
761761+817762 const currQueuedPlay = this.queuedScrobbles.shift();
818763819819- const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play);
820820- if (timeFrameValid) {
821821- const [matched, matchResult] = await this.alreadyScrobbled(currQueuedPlay.play);
764764+ let historicalPlays: PlayObject[] = [];
765765+ let historicalError: Error | undefined;
766766+767767+ if(this.upstreamRefresh.refreshEnabled) {
768768+ try {
769769+ historicalPlays = await this.getSOTScrobblesForPlay(currQueuedPlay.play);
770770+ } catch (e) {
771771+ historicalError = e;
772772+ if(e.message === 'Cannot get historical plays due to cached error') {
773773+ this.logger.warn(`${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' => Previous error while getting historical scrobbles means this scrobble cannot be compared, will queue as dead for now.`);
774774+ this.logger.trace(e);
775775+ } else {
776776+ this.logger.warn(new SimpleError(`${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' => cannot get historical scrobbles, will queue as dead for now.`, {cause: e, shortStack: true}));
777777+ }
778778+ this.addDeadLetterScrobble(currQueuedPlay, e);
779779+ }
780780+ }
781781+ if(historicalError === undefined) {
782782+ const {summary, ...matchResult} = await this.existingScrobble(currQueuedPlay.play, historicalPlays);
822783 const {
823784 scrobble = {},
824785 ...lifeRest
···830791 match: matchResult
831792 }
832793 }
833833- if(!matched) {
794794+ if(!matchResult.match) {
834795 const transformedScrobble = await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare);
835796 if(transformedScrobble.meta.lifecycle === undefined) {
836797 transformedScrobble.meta.lifecycle = {
···866827 }
867828 }
868829 }
869869- } else if (!timeFrameValid) {
870870- this.logger.debug(`Will not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' because it ${timeFrameValidLog}`);
871830 }
872831 this.updateQueuedScrobblesCache();
873832 this.queuedGauge.labels(this.getPrometheusLabels()).set(this.queuedScrobbles.length);
874833 this.emitEvent('scrobbleDequeued', {queuedScrobble: currQueuedPlay})
834834+ }
835835+ if(!queueEmpty) {
836836+ this.emitEvent('queueEmptied', {});
875837 }
876838 await sleep(this.scrobbleSleep);
877839 }
···905867 const processable = this.deadLetterScrobbles.filter(x => x.retries < retries);
906868 const queueStatus = `${processable.length} of ${this.deadLetterScrobbles.length} dead scrobbles have less than ${retries} retries, ${processable.length === 0 ? 'will skip processing.': 'processing now...'}`;
907869 if (processable.length === 0) {
908908- this.logger.verbose(queueStatus, {leaf: 'Dead Letter'});
870870+ this.logger.verbose({labels: 'Dead Letter'}, queueStatus);
909871 return;
910872 }
911911- this.logger.info(queueStatus, {leaf: 'Dead Letter'});
873873+ this.logger.info({labels: 'Dead Letter'}, queueStatus);
874874+ if(!this.upstreamRefresh.refreshEnabled) {
875875+ this.logger.verbose({labels: 'Dead Letter'}, 'Scrobble refresh is DISABLED. All dead scrobbles will likely always be scrobbled (nothing to check duplicates against).');
876876+ }
877877+ this.handleQueuedScrobbleRanges();
912878913879 const removedIds = [];
914880 for (const deadScrobble of this.deadLetterScrobbles) {
···920886 }
921887 }
922888 if (removedIds.length > 0) {
923923- this.logger.info(`Removed ${removedIds.length} scrobbles from dead letter queue`, {leaf: 'Dead Letter'});
889889+ this.logger.info({labels: 'Dead Letter'}, `Removed ${removedIds.length} scrobbles from dead letter queue`);
924890 }
925891 }
926892···929895 const deadScrobble = this.deadLetterScrobbles[deadScrobbleIndex];
930896931897 if (!(await this.isReady())) {
932932- this.logger.warn('Cannot process dead letter scrobble because client is not ready.', {leaf: 'Dead Letter'});
898898+ this.logger.warn({labels: 'Dead Letter'}, 'Cannot process dead letter scrobble because client is not ready.');
933899 return [false, deadScrobble];
934900 }
935935- if (this.getLatestQueuePlayDate() !== undefined && this.scrobblesLastCheckedAt().unix() < this.getLatestQueuePlayDate().unix()) {
936936- await this.refreshScrobbles();
937937- }
938938- const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(deadScrobble.play);
939939- if (timeFrameValid) {
940940- const [matched, matchResult] = await this.alreadyScrobbled(deadScrobble.play);
941941- const {
942942- scrobble = {},
943943- ...lifeRest
944944- } = deadScrobble.play.meta.lifecycle ?? {steps: [], original: deadScrobble.play};
945945- deadScrobble.play.meta.lifecycle = {
946946- ...lifeRest,
947947- scrobble: {
948948- ...scrobble,
949949- match: matchResult[1]
901901+ let historicalPlays: PlayObject[] = [];
902902+ if(this.upstreamRefresh.refreshEnabled) {
903903+ try {
904904+ historicalPlays = await this.getSOTScrobblesForPlay(deadScrobble.play);
905905+ } catch (e) {
906906+ if(e.message === 'Cannot get historical plays due to cached error') {
907907+ this.logger.warn(`${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' => Previous error while getting historical scrobbles means this scrobble cannot be compared`);
908908+ this.logger.trace(e);
909909+ } else {
910910+ this.logger.warn(new SimpleError(`${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' => cannot get historical scrobbles`, {cause: e, shortStack: true}));
950911 }
912912+ deadScrobble.retries++;
913913+ deadScrobble.error = messageWithCauses(e);
914914+ deadScrobble.lastRetry = dayjs();
915915+ this.deadLetterScrobbles[deadScrobbleIndex] = deadScrobble;
916916+ this.updateDeadLetterCache();
917917+ return [false, deadScrobble];
918918+ } finally {
919919+ await sleep(1000);
951920 }
952952- if(!matched) {
953953- const transformedScrobble = await this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.postCompare);
954954- try {
955955- const scrobbledPlay = await this.scrobble(transformedScrobble);
956956- this.emitEvent('scrobble', {play: transformedScrobble});
957957- this.addScrobbledTrack(transformedScrobble, scrobbledPlay);
958958- } catch (e) {
921921+ }
922922+ const {summary, ...matchResult} = await this.existingScrobble(deadScrobble.play, historicalPlays);
923923+ const {
924924+ scrobble = {},
925925+ ...lifeRest
926926+ } = deadScrobble.play.meta.lifecycle ?? {steps: [], original: deadScrobble.play};
927927+ deadScrobble.play.meta.lifecycle = {
928928+ ...lifeRest,
929929+ scrobble: {
930930+ ...scrobble,
931931+ match: matchResult
932932+ }
933933+ }
934934+ if(!matchResult.match) {
935935+ const transformedScrobble = await this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.postCompare);
936936+ try {
937937+ const scrobbledPlay = await this.scrobble(transformedScrobble);
938938+ this.emitEvent('scrobble', {play: transformedScrobble});
939939+ this.addScrobbledTrack(transformedScrobble, scrobbledPlay);
940940+ } catch (e) {
959941960960- const submitError = findCauseByReference(e, ScrobbleSubmitError);
961961- if(submitError !== undefined) {
962962- deadScrobble.play.meta.lifecycle.scrobble.payload = submitError.payload;
963963- deadScrobble.play.meta.lifecycle.scrobble.response = submitError.responseBody;
964964- deadScrobble.play.meta.lifecycle.scrobble.error = serializeError(submitError);
965965- } else {
966966- deadScrobble.play.meta.lifecycle.scrobble.payload = this.playToClientPayload(transformedScrobble);
967967- deadScrobble.play.meta.lifecycle.scrobble.error = serializeError(e);
968968- }
942942+ const submitError = findCauseByReference(e, ScrobbleSubmitError);
943943+ if(submitError !== undefined) {
944944+ deadScrobble.play.meta.lifecycle.scrobble.payload = submitError.payload;
945945+ deadScrobble.play.meta.lifecycle.scrobble.response = submitError.responseBody;
946946+ deadScrobble.play.meta.lifecycle.scrobble.error = serializeError(submitError);
947947+ } else {
948948+ deadScrobble.play.meta.lifecycle.scrobble.payload = this.playToClientPayload(transformedScrobble);
949949+ deadScrobble.play.meta.lifecycle.scrobble.error = serializeError(e);
950950+ }
969951970970- deadScrobble.retries++;
971971- deadScrobble.error = messageWithCauses(e);
972972- deadScrobble.lastRetry = dayjs();
973973- this.logger.error(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${deadScrobble.source}' due to error`, {cause: e}));
974974- this.deadLetterScrobbles[deadScrobbleIndex] = deadScrobble;
975975- return [false, deadScrobble];
976976- } finally {
977977- await sleep(1000);
978978- }
952952+ deadScrobble.retries++;
953953+ deadScrobble.error = messageWithCauses(e);
954954+ deadScrobble.lastRetry = dayjs();
955955+ this.logger.error(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${deadScrobble.source}' due to error`, {cause: e}));
956956+ this.deadLetterScrobbles[deadScrobbleIndex] = deadScrobble;
957957+ this.updateDeadLetterCache();
958958+ return [false, deadScrobble];
959959+ } finally {
960960+ await sleep(1000);
979961 }
980980- } else if (!timeFrameValid) {
981981- this.logger.debug(`Will not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' because it ${timeFrameValidLog}`, {leaf: 'Dead Letter'});
982962 }
983963 if(deadScrobble !== undefined) {
984964 this.removeDeadLetterScrobble(deadScrobble.id)
···1016996 const plays = Array.isArray(data) ? data : [data];
1017997 const sm = staggerMapper<PlayObject, PlayObject>({concurrency: 2});
1018998 for await(const play of pMapIterable(plays, sm(async x => await this.transformPlay(x, TRANSFORM_HOOK.preCompare)), {concurrency: 2})) {
999999+ try {
10001000+ const existingQueued = await this.existingScrobble(play, this.queuedScrobbles.map(x => x.play), false);
10011001+ // want to be very confident of this
10021002+ if(existingQueued.match && existingQueued.score > 0.99) {
10031003+ this.logger.trace(`Not adding to queue because it is already in the queue\n${existingQueued.summary}`);
10041004+ return;
10051005+ }
10061006+ } catch (e) {
10071007+ this.logger.warn(new SimpleError('Failed to check queued scrobble for existing before adding', {cause: e}));
10081008+ }
10191009 const queuedPlay = {id: nanoid(), source, play: play}
10201010 this.emitEvent('scrobbleQueued', {queuedPlay: queuedPlay});
10211011 this.queuedScrobbles.push(queuedPlay);
···10241014 this.queuedScrobbles.sort((a, b) => sortByOldestPlayDate(a.play, b.play));
10251015 }
10261016 this.updateQueuedScrobblesCache();
10171017+ }
10181018+10191019+ cancelQueuedItemsBySource = (source: string): number => {
10201020+ const beforeMain = this.queuedScrobbles.length;
10211021+ const beforeDead = this.deadLetterScrobbles.length;
10221022+10231023+ this.queuedScrobbles = this.queuedScrobbles.filter(item => item.source !== source);
10241024+ this.deadLetterScrobbles = this.deadLetterScrobbles.filter(item => item.source !== source);
10251025+10261026+ this.updateQueuedScrobblesCache();
10271027+ this.updateDeadLetterCache();
10281028+10291029+ return (beforeMain + beforeDead) - (this.queuedScrobbles.length + this.deadLetterScrobbles.length);
10271030 }
1028103110291032 protected addDeadLetterScrobble = (data: QueuedScrobble<PlayObject>, error: (Error | string) = 'Unspecified error') => {
···1141114411421145 protected updateDeadLetterCache = () => {
11431146 this.cache.cacheScrobble.set(`${this.getMachineId()}-dead`, this.deadLetterScrobbles)
11441144- .then(() => isDebugMode() ? this.logger.debug('Updated dead letter cache') : null)
11471147+ .then(() => null)
11451148 .catch((e) => this.logger.warn(new Error('Error while updating dead letter cache', {cause: e})));
11461149 }
1147115011481151 protected updateQueuedScrobblesCache = () => {
11491152 this.cache.cacheScrobble.set(`${this.getMachineId()}-queue`, this.queuedScrobbles)
11501150- .then(() => isDebugMode() ? this.logger.debug('Updated queued scrobble cache') : null)
11531153+ .then(() => null)
11511154 .catch((e) => this.logger.warn(new Error('Error while updating queued scrobble cache', {cause: e})));
11521155 }
11531156}
···11601163}
1161116411621165export const shouldUpdatePlayingNowPlatformWhenPlayingOnly = async (data: SourcePlayerObj): Promise<boolean> => {
11631163- return data.status.calculated === CALCULATED_PLAYER_STATUSES.playing;
11661166+ return (data.status.calculated === CALCULATED_PLAYER_STATUSES.playing)
11671167+ || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped);
11641168}
+14-7
src/backend/scrobblers/DiscordScrobbler.ts
···11import { Logger } from "@foxxmd/logging";
22import EventEmitter from "events";
33import { PlayMatchResult, PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
44-import { CALCULATED_PLAYER_STATUSES, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, SINGLE_USER_PLATFORM_ID_STR } from "../common/infrastructure/Atomic.js";
44+import { CALCULATED_PLAYER_STATUSES, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, SINGLE_USER_PLATFORM_ID_STR, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js";
55import { Notifiers } from "../notifier/Notifiers.js";
6677import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js";
···2929 this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration;
3030 this.nowPlayingMinThreshold = (_) => 5;
3131 }
3232+3333+ getScrobblesForTimeRange = async (_) => [];
32343335 formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => obj;
3436···114116 }
115117 }
116118117117- getScrobblesForRefresh = async (limit: number) => {
118118- return [];
119119- }
120120-121119 queueScrobble = async (data: PlayObject | PlayObject[], source: string) => {
122120 // discord does not handle scrobbles, only Now Playing
123121 // so don't bother queueing any scrobbles as we don't want to cache them
···160158161159 shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj) => {
162160 if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)
161161+ || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)
163162 || data.status.stale) {
164163165164 const [sendOk, reasons, level = 'warn'] = await this.api.checkOkToSend();
166165 if (!sendOk) {
167167- this.logger[level](`Cannot update playing now because api client is ${reasons}`);
166166+ this.npLogger[level](`Cannot update playing now because api client is ${reasons}`);
168167 return false;
169168 }
170169171170 if(this.api instanceof DiscordWSClient) {
172171 const [allowed, reason] = this.api.presenceIsAllowed();
173172 if(!allowed) {
174174- this.logger.debug(reason);
173173+ this.npLogger.debug(reason);
175174 }
176175177176 return true;
178177 }
179178180179 return true;
180180+ } else {
181181+ if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) {
182182+ this.npLogger.trace(`Will not update because player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`);
183183+ } else if(data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) {
184184+ this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`);
185185+ } else {
186186+ this.npLogger.trace('Will not update because now player is in an unexpected state for discord usage');
187187+ }
181188 }
182189 }
183190}
···11import EventEmitter from "events";
22-import request from "superagent";
32import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
43import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
55-import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
66-import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
77-import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
44+import { FormatPlayObjectOptions, InternalConfig, PaginatedListensTimeRangeOptions, PaginatedTimeRangeListens, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js";
85import { RecentlyPlayedOptions } from "./AbstractSource.js";
96import MemorySource from "./MemorySource.js";
1010-import { isPortReachableConnect } from "../utils/NetworkUtils.js";
117import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
128import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js";
99+import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js";
13101411export default class KoitoSource extends MemorySource {
15121613 api: KoitoApiClient;
1714 requiresAuth = true;
1815 requiresAuthInteraction = false;
1616+ getScrobblesForTimeRange: TimeRangeListensFetcher
19172018 declare config: KoitoSourceConfig;
2119···3533 this.supportsUpstreamRecentlyPlayed = true
3634 this.SCROBBLE_BACKLOG_COUNT = 100;
3735 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.`)
3636+ this.getScrobblesForTimeRange = createGetScrobblesForTimeRangeFunc(this.api, this.api.logger);
3837 }
39384039 static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return listenObjectResponseToPlay(obj, options); }
···6261 getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
6362 const {limit = 20} = options;
6463 await this.processRecentPlays([]);
6565- return await this.api.getRecentlyPlayed(limit);
6464+ const resp = await this.getScrobblesForTimeRange({limit, cursor: 0 });
6565+ return resp;
6666 }
67676868 getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
6969 try {
7070- return await this.api.getRecentlyPlayed(20);
7070+ const resp = await this.getScrobblesForTimeRange({limit: 20, cursor: 0 });
7171+ return resp;
7172 } catch (e) {
7273 throw e;
7374 }
+10-9
src/backend/sources/LastfmSource.ts
···11-import dayjs from "dayjs";
21import EventEmitter from "events";
33-import request from "superagent";
42import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
55-import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
66-import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId, SourceType } from "../common/infrastructure/Atomic.js";
33+import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId, SourceType, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js";
74import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
85import LastfmApiClient, { formatPlayObj } from "../common/vendor/LastfmApiClient.js";
96import { sortByOldestPlayDate } from "../utils.js";
···129import { Logger } from "@foxxmd/logging";
1310import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
1411import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
1212+import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js";
15131614export default class LastfmSource extends MemorySource {
1715···1917 requiresAuth = true;
2018 requiresAuthInteraction = true;
2119 upstreamType: string = 'Last.fm';
2020+ getScrobblesForTimeRange: TimeRangeListensFetcher
22212322 declare config: LastfmSourceConfig;
2423···3938 this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
4039 // https://www.last.fm/api/show/user.getRecentTracks
4140 this.SCROBBLE_BACKLOG_COUNT = 200;
4242- 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.`)
4141+ 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.`);
4242+ this.getScrobblesForTimeRange = createGetScrobblesForTimeRangeFunc(this.api, this.api.logger);
4343 }
44444545 static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
···7171 getLastfmRecentTrack = async(options: RecentlyPlayedOptions = {}): Promise<[PlayObject[], PlayObject[]]> => {
7272 const {limit = 20} = options;
7373 try {
7474- const plays = await this.api.getRecentTracks({limit});
7575- plays.sort(sortByOldestPlayDate);
7474+ const {data: plays} = await this.api.getPaginatedTimeRangeListens({limit, cursor: 1}, {includeNowPlaying: true});
7575+ const mappedPlayed: PlayObject[] = plays.map(x => ({...x, meta: {...x.meta, sourceSOT: SOURCE_SOT.HISTORY}}));
7676+ mappedPlayed.sort(sortByOldestPlayDate);
7677 // if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
7778 // and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
7879 // so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
7979- const history = plays.filter(x => x.meta.nowPlaying !== true);
8080- const now = plays.filter(x => x.meta.nowPlaying === true);
8080+ const history = mappedPlayed.filter(x => x.meta.nowPlaying !== true);
8181+ const now = mappedPlayed.filter(x => x.meta.nowPlaying === true);
8182 return [history, now];
8283 } catch (e) {
8384 throw e;
+19-12
src/backend/sources/ListenbrainzSource.ts
···11import EventEmitter from "events";
22-import request from "superagent";
32import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
43import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
55-import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js";
44+import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js";
65import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
76import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
87import { RecentlyPlayedOptions } from "./AbstractSource.js";
···1110import { Logger } from "@foxxmd/logging";
1211import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
1312import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
1313+import { ManipulateType } from "dayjs";
1414+import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js";
14151516export default class ListenbrainzSource extends MemorySource {
16171718 api: ListenbrainzApiClient;
1819 requiresAuth = true;
1920 requiresAuthInteraction = false;
2020- isKoito: boolean = false;
2121-2121+ getScrobblesForTimeRange: TimeRangeListensFetcher
2222 declare config: ListenBrainzSourceConfig;
23232424 constructor(name: any, config: ListenBrainzSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
···3939 // 1000 is way too high. maxing at 100
4040 this.SCROBBLE_BACKLOG_COUNT = 100;
4141 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.`)
4242+ this.getScrobblesForTimeRange = createGetScrobblesForTimeRangeFunc(this.api, this.api.logger);
4243 }
43444444- static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return ListenbrainzApiClient.formatPlayObj(obj, options); }
4545+ static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){
4646+ const play = ListenbrainzApiClient.formatPlayObj(obj, options);
4747+ play.meta.sourceSOT = SOURCE_SOT.HISTORY;
4848+ return play;
4949+ }
45504651 protected async doCheckConnection(): Promise<true | string | undefined> {
4752 try {
4853 await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname});
4954 const isKoito = await this.api.checkKoito();
5050- this.api.isKoito = isKoito;
5151- this.isKoito = isKoito;
5555+ if(isKoito) {
5656+ throw new Error('The given URL looks like a Koito instance. Use the Koito Source instead of Listenbrainz.')
5757+ }
5258 return true;
5359 } catch (e) {
5460 if(isNodeNetworkException(e)) {
···75817682 getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
7783 const {limit = 20} = options;
7878- if(this.isKoito) {
7979- return await this.api.getRecentlyPlayedKoito(limit);
8080- }
8184 const now = await this.api.getPlayingNow();
8285 await this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
8383- return await this.api.getRecentlyPlayed(limit);
8686+ return await this.getScrobblesForTimeRange({limit});
8487 }
85888689 getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
8790 try {
8888- return await this.api.getRecentlyPlayed(20);
9191+ return await this.getScrobblesForTimeRange({limit: 20});
8992 } catch (e) {
9093 throw e;
9194 }
9595+ }
9696+9797+ getPaginatedUnitOfTime(): ManipulateType {
9898+ return 'second';
9299 }
9310094101 protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})