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

Configure Feed

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

implement time range scrobbles for teal

FoxxMD (Mar 4, 2026, 8:59 PM UTC) 7b61385a b5c86e74

+90 -27
+50
package-lock.json
··· 12 12 "dependencies": { 13 13 "@astronautlabs/mdns": "^1.0.7", 14 14 "@atcute/identity-resolver-node": "^1.0.3", 15 + "@atcute/tid": "^1.1.1", 15 16 "@atproto/api": "^0.18.0", 16 17 "@atproto/oauth-client-node": "^0.3.10", 17 18 "@awaitjs/express": "^0.6.3", ··· 234 235 "dependencies": { 235 236 "@standard-schema/spec": "^1.0.0", 236 237 "esm-env": "^1.2.2" 238 + } 239 + }, 240 + "node_modules/@atcute/tid": { 241 + "version": "1.1.1", 242 + "resolved": "https://registry.npmjs.org/@atcute/tid/-/tid-1.1.1.tgz", 243 + "integrity": "sha512-djJ8UGhLkTU5V51yCnBEruMg35qETjWzWy5sJG/2gEOl2Gd7rQWHSaf+yrO6vMS5EFA38U2xOWE3EDUPzvc2ZQ==", 244 + "license": "0BSD", 245 + "dependencies": { 246 + "@atcute/time-ms": "^1.0.0" 247 + } 248 + }, 249 + "node_modules/@atcute/time-ms": { 250 + "version": "1.2.1", 251 + "resolved": "https://registry.npmjs.org/@atcute/time-ms/-/time-ms-1.2.1.tgz", 252 + "integrity": "sha512-7tzcYMLz1IQCyneupNP5AMkgfLywzYnsWLTW/n1Ku3vM/LgicGgBVbHKhqP10H8LUz3gEf8DUMrKbTkM/VLUgA==", 253 + "hasInstallScript": true, 254 + "license": "0BSD", 255 + "dependencies": { 256 + "@types/bun": "^1.3.8", 257 + "node-gyp-build": "^4.8.4" 237 258 } 238 259 }, 239 260 "node_modules/@atcute/util-fetch": { ··· 3727 3748 "@types/node": "*" 3728 3749 } 3729 3750 }, 3751 + "node_modules/@types/bun": { 3752 + "version": "1.3.9", 3753 + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", 3754 + "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", 3755 + "license": "MIT", 3756 + "dependencies": { 3757 + "bun-types": "1.3.9" 3758 + } 3759 + }, 3730 3760 "node_modules/@types/cacheable-request": { 3731 3761 "version": "6.0.3", 3732 3762 "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", ··· 4987 5017 "version": "1.1.2", 4988 5018 "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 4989 5019 "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" 5020 + }, 5021 + "node_modules/bun-types": { 5022 + "version": "1.3.9", 5023 + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", 5024 + "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", 5025 + "license": "MIT", 5026 + "dependencies": { 5027 + "@types/node": "*" 5028 + } 4990 5029 }, 4991 5030 "node_modules/bytes": { 4992 5031 "version": "3.1.2", ··· 9891 9930 "encoding": { 9892 9931 "optional": true 9893 9932 } 9933 + } 9934 + }, 9935 + "node_modules/node-gyp-build": { 9936 + "version": "4.8.4", 9937 + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", 9938 + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", 9939 + "license": "MIT", 9940 + "bin": { 9941 + "node-gyp-build": "bin.js", 9942 + "node-gyp-build-optional": "optional.js", 9943 + "node-gyp-build-test": "build-test.js" 9894 9944 } 9895 9945 }, 9896 9946 "node_modules/node-object-hash": {
+1
package.json
··· 47 47 "dependencies": { 48 48 "@astronautlabs/mdns": "^1.0.7", 49 49 "@atcute/identity-resolver-node": "^1.0.3", 50 + "@atcute/tid": "^1.1.1", 50 51 "@atproto/api": "^0.18.0", 51 52 "@atproto/oauth-client-node": "^0.3.10", 52 53 "@awaitjs/express": "^0.6.3",
+32 -8
src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
··· 1 1 import { getRoot } from "../../../ioc.js"; 2 - import { AbstractApiOptions } from "../../infrastructure/Atomic.js"; 2 + import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../../infrastructure/Atomic.js"; 3 3 import { ListRecord, ScrobbleRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; 4 4 import AbstractApiClient from "../AbstractApiClient.js"; 5 - import { Agent, ComAtprotoRepoCreateRecord } from "@atproto/api"; 5 + import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords } from "@atproto/api"; 6 6 import { MSCache } from "../../Cache.js"; 7 7 import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult } from "../../../../core/Atomic.js"; 8 8 import { musicServiceToCononical } from "../ListenbrainzApiClient.js"; 9 9 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 10 10 import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; 11 - import dayjs from "dayjs"; 11 + import dayjs, { ManipulateType } from "dayjs"; 12 12 import { getScrobbleTsSOCDateWithContext } from "../../../utils/TimeUtils.js"; 13 13 import { removeUndefinedKeys } from "../../../utils.js"; 14 14 import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; 15 15 import { ScrobbleSubmitError } from "../../errors/MSErrors.js"; 16 16 import { UpstreamError } from "../../errors/UpstreamError.js"; 17 + import * as TID from '@atcute/tid'; 18 + import { randomInt } from "node:crypto"; 17 19 18 - 19 - export abstract class AbstractBlueSkyApiClient extends AbstractApiClient { 20 + export abstract class AbstractBlueSkyApiClient extends AbstractApiClient implements PagelessTimeRangeListens { 20 21 21 22 declare config: TealClientData; 22 23 ··· 48 49 } 49 50 } 50 51 51 - async listScrobbleRecord(limit: number = 20): Promise<ListRecord<ScrobbleRecord>[]> { 52 + async listScrobbleRecord(options: {limit?: number, cursor?: string} = {}): Promise<ComAtprotoRepoListRecords.Response> { 53 + const {limit = 20, cursor} = options; 52 54 try { 55 + // records are returned newest to oldest 53 56 const response = await this.agent.com.atproto.repo.listRecords({ 54 57 repo: this.agent.sessionManager.did, 55 58 collection: "fm.teal.alpha.feed.play", 56 - limit 59 + limit, 60 + cursor // cursor TID is EXCLUSIVE IE first record returned will be the first older than cursor 57 61 }); 58 - return response.data.records as unknown as ListRecord<ScrobbleRecord>[]; 62 + return response; 59 63 } catch (e) { 60 64 throw new UpstreamError(`Failed to list scrobble record`, { cause: e, response: 'response' in e ? e.response : undefined }); 61 65 } 66 + } 67 + 68 + async getPagelessTimeRangeListens(params: PagelessListensTimeRangeOptions): Promise<PagelessTimeRangeListensResult> { 69 + const {to, limit} = params; 70 + 71 + let cursor: string; 72 + if(to !== undefined) { 73 + cursor = TID.create(to, randomInt(1023)); 74 + } 75 + 76 + const resp = await this.listScrobbleRecord({cursor, limit}); 77 + const fromTS = TID.parse(resp.data.cursor); 78 + 79 + const plays = (resp.data.records as unknown as ListRecord<ScrobbleRecord>[]).map(x => listRecordToPlay(x)); 80 + 81 + return {data: plays, meta: {to, from: fromTS.timestamp, limit}}; 82 + } 83 + 84 + getPaginatedUnitOfTime(): ManipulateType { 85 + return 'second'; 62 86 } 63 87 } 64 88
+3 -5
src/backend/scrobblers/TealfmScrobbler.ts
··· 3 3 import { PlayObject } from "../../core/Atomic.js"; 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 - import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 7 6 import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 8 7 import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; 9 8 import { Notifiers } from "../notifier/Notifiers.js"; 10 9 11 10 import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 12 - import { ListRecord, ScrobbleRecord, TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; 11 + import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; 13 12 import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; 14 13 import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; 15 14 import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; ··· 93 92 } 94 93 95 94 getScrobblesForRefresh = async (limit: number) => { 96 - let list: ListRecord<ScrobbleRecord>[]; 97 95 try { 98 - list = await this.client.listScrobbleRecord(limit) 96 + const {data} = await this.client.getPagelessTimeRangeListens({limit}) 97 + return data; 99 98 } catch (e) { 100 99 throw new Error('Error occurred while trying to fetch records', {cause: e}); 101 100 } 102 - return list.map(x => listRecordToPlay(x)); 103 101 } 104 102 105 103 doScrobble = async (playObj: PlayObject) => {
+1 -10
src/backend/sources/ListenbrainzSource.ts
··· 13 13 import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; 14 14 import { ManipulateType } from "dayjs"; 15 15 16 - export default class ListenbrainzSource extends MemorySource implements PagelessTimeRangeListens { 16 + export default class ListenbrainzSource extends MemorySource { 17 17 18 18 api: ListenbrainzApiClient; 19 19 requiresAuth = true; ··· 90 90 } catch (e) { 91 91 throw e; 92 92 } 93 - } 94 - 95 - getPagelessTimeRangeListens = async (options: PagelessListensTimeRangeOptions) => { 96 - const resp = await this.api.getUserListensWithPagination({ 97 - count: options.limit, 98 - minTs: options.from, 99 - maxTs: options.to 100 - }); 101 - return {data: resp.listens.map(x => ListenbrainzSource.formatPlayObj(x)), meta: {...options, total: resp.count}}; 102 93 } 103 94 104 95 getPaginatedUnitOfTime(): ManipulateType {
+3 -4
src/backend/sources/TealfmSource.ts
··· 8 8 import { TealSourceConfig } from "../common/infrastructure/config/source/tealfm.js"; 9 9 import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; 10 10 import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; 11 - import { ListRecord, ScrobbleRecord } from "../common/infrastructure/config/client/tealfm.js"; 12 11 import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; 13 12 14 13 export default class TealfmSource extends MemorySource { ··· 107 106 108 107 getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => { 109 108 const {limit = 20} = options; 110 - let list: ListRecord<ScrobbleRecord>[]; 109 + let plays: PlayObject[]; 111 110 try { 112 - list = await this.client.listScrobbleRecord(limit) 111 + const {data} = await this.client.getPagelessTimeRangeListens({limit}) 112 + plays = data; 113 113 } catch (e) { 114 114 throw new Error('Error occurred while trying to fetch records', {cause: e}); 115 115 } 116 116 await this.processRecentPlays([]); 117 - let plays = list.map(x => listRecordToPlay(x)); 118 117 if(this.serviceAllow.length > 0) { 119 118 plays = plays.filter(x => 120 119 (x.meta.musicService !== undefined && this.serviceAllow.some(y => x.meta.musicService.toLocaleLowerCase().includes(y)))