[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.

refactor: Move more atproto-generic function into abstract api base

FoxxMD (Jun 4, 2026, 3:37 PM UTC) c1cdcd01 fcb9092f

+136 -118
+23
src/backend/common/infrastructure/config/client/atproto.ts
··· 1 + import { AtprotoDid, DidDocument } from "@atproto/oauth-client-node"; 2 + 3 + export interface ATProtoUserIdentifierData { 4 + /** 5 + * Identify the account to login as 6 + * 7 + * * For **App Password** Auth - your email 8 + * * For **Oauth** - your handle minus the @ 9 + */ 10 + identifier: string 11 + did?: AtprotoDid 12 + } 13 + 14 + export interface ATProtoAppData { 15 + /** 16 + * The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account 17 + * 18 + * This is created under https://bsky.app/settings/app-passwords 19 + * 20 + * **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT** 21 + */ 22 + appPassword: string 23 + }
+2 -16
src/backend/common/infrastructure/config/client/tealfm.ts
··· 1 1 import { RequestRetryOptions } from "../common.js" 2 + import { ATProtoAppData, ATProtoUserIdentifierData } from "./atproto.js" 2 3 import { CommonClientConfig, CommonClientData, CommonClientOptions } from "./index.js" 3 4 4 - export interface TealData extends RequestRetryOptions { 5 + export type TealData = RequestRetryOptions & ATProtoUserIdentifierData & Partial<ATProtoAppData> & { 5 6 /** 6 7 * The base URI of the Multi-Scrobbler to use for ATProto OAuth 7 8 * 8 9 * Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol. 9 10 */ 10 11 baseUri?: string 11 - /** 12 - * Identify the account to login as 13 - * 14 - * * For **App Password** Auth - your email 15 - * * For **Oauth** - your handle minus the @ 16 - */ 17 - identifier: string 18 - /** 19 - * The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account 20 - * 21 - * This is created under https://bsky.app/settings/app-passwords 22 - * 23 - * **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT** 24 - */ 25 - appPassword?: string 26 12 } 27 13 28 14 export interface TealClientData extends TealData, CommonClientData {
+7 -98
src/backend/common/vendor/atproto/ATProtoAppApiClient.ts
··· 2 2 import { TealClientData } from "../../infrastructure/config/client/tealfm.js"; 3 3 import { Agent, CredentialSession, AtpSessionEvent, AtpSessionData } from "@atproto/api"; 4 4 import { AbstractATProtoApiClient } from "./AbstractATProtoApiClient.js"; 5 - import { isPortReachableConnect, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; 6 - import { URLData } from "../../../../core/Atomic.js"; 7 - import { isNodeNetworkException } from "../../errors/NodeErrors.js"; 8 - import { 9 - CompositeDidDocumentResolver, 10 - CompositeHandleResolver, 11 - DohJsonHandleResolver, 12 - PlcDidDocumentResolver, 13 - WebDidDocumentResolver, 14 - WellKnownHandleResolver, 15 - } from "@atcute/identity-resolver"; 16 - import { AtprotoDid, DidDocument } from "@atproto/oauth-client-node"; 17 5 import { identifierToAtProtoHandle, isDID } from "./atUtils.js"; 18 - 19 - interface HandleData { 20 - did: string 21 - pds: string 22 - } 6 + import { ATProtoAppData, ATProtoUserIdentifierData } from "../../infrastructure/config/client/atproto.js"; 23 7 24 8 export class ATProtoAppApiClient extends AbstractATProtoApiClient { 25 9 26 - declare config: TealClientData & {did?: AtprotoDid}; 10 + declare config: ATProtoUserIdentifierData & ATProtoAppData; 27 11 appSession?: CredentialSession; 28 12 appPwAuth: boolean 29 13 ··· 38 22 } else { 39 23 this.config.identifier = identifierToAtProtoHandle(this.config.identifier, {logger: this.logger, defaultDomain: 'bsky.social'}); 40 24 } 25 + if(this.config.appPassword === undefined) { 26 + throw new Error('Must provide app password'); 27 + } 41 28 } 42 29 43 30 async initClient(): Promise<void> { 44 - const hd = await this.getATProtoIdentifier(); 31 + const hd = await this.getATProtoIdentifier(this.config); 45 32 this.logger.verbose(`Using ${hd.did} on PDS ${hd.pds}`); 46 33 this.appSession = new CredentialSession(new URL(hd.pds), undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { 47 34 this.cache.cacheAuth.set(`appPwSession-${this.name}-${hd.did}`, sess, '1000h'); ··· 49 36 this.agent = new Agent(this.appSession); 50 37 } 51 38 52 - protected async getATProtoIdentifier(): Promise<HandleData> { 53 - 54 - let hd: HandleData; 55 - hd = await this.cache.cacheAuth.get<HandleData>(`${this.name}-handleData`); 56 - if (hd !== undefined) { 57 - this.logger.debug('Found cached handle data'); 58 - return hd; 59 - } else { 60 - this.logger.debug('Handle data not cached, attempting to resolve...'); 61 - } 62 - 63 - const handleResolver = new CompositeHandleResolver({ 64 - strategy: "race", 65 - methods: { 66 - dns: new DohJsonHandleResolver({ 67 - dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 68 - }), 69 - http: new WellKnownHandleResolver(), 70 - }, 71 - }); 72 - 73 - let did: AtprotoDid = this.config.did; 74 - if(did === undefined) { 75 - try { 76 - did = await handleResolver.resolve(this.config.identifier as `${string}.${string}`); 77 - this.logger.debug(`Resolved ${did}`); 78 - } catch (e) { 79 - throw new Error('Unable to resolve handle', { cause: e }); 80 - } 81 - } 82 - 83 - const docResolver = new CompositeDidDocumentResolver({ 84 - methods: { 85 - plc: new PlcDidDocumentResolver(), 86 - web: new WebDidDocumentResolver(), 87 - }, 88 - }); 89 - 90 - let doc: Awaited<ReturnType<typeof docResolver.resolve>>; 91 - try { 92 - doc = await docResolver.resolve(did); 93 - } catch (e) { 94 - throw new Error('Unable to resolve did document', { cause: e }); 95 - } 96 - if (doc.service === undefined || doc.service.length === 0) { 97 - throw new Error('did document did not return a service'); 98 - } 99 - 100 - if (typeof doc.service[0].serviceEndpoint !== 'string') { 101 - throw new Error(`Do not know how to handle this serviceEndpoint data structure!\n${JSON.stringify(doc.service[0].serviceEndpoint)}`); 102 - } 103 - hd = { did, pds: doc.service[0].serviceEndpoint }; 104 - 105 - this.cache.cacheAuth.set(`${this.name}-handleData`, hd, '1d'); 106 - return hd; 107 - } 108 - 109 39 restoreSession = async (): Promise<boolean> => { 110 - const hd = await this.getATProtoIdentifier(); 40 + const hd = await this.getATProtoIdentifier(this.config); 111 41 const savedSession = await this.cache.cacheAuth.get<AtpSessionData>(`appPwSession-${this.name}-${hd.did}`); 112 42 if (savedSession !== undefined) { 113 43 try { ··· 139 69 } catch (e) { 140 70 this.logger.error(new Error('Could not login using app password', { cause: e })); 141 71 return false; 142 - } 143 - } 144 - 145 - async checkPds(): Promise<true> { 146 - let hd: HandleData; 147 - try { 148 - hd = await this.getATProtoIdentifier(); 149 - } catch(e) { 150 - throw new Error('Unable to get handle data', {cause: e}); 151 - } 152 - 153 - const normal = normalizeWebAddress(hd.pds); 154 - 155 - try { 156 - await isPortReachableConnect(normal.port, {host: normal.url.hostname}); 157 - return true; 158 - } catch (e) { 159 - if(isNodeNetworkException(e)) { 160 - throw new Error('Could not communicate with PDS server', {cause: e}); 161 - } 162 - throw new Error('Unexpected error when trying to communicate with PDS server', {cause: e}); 163 72 } 164 73 } 165 74
+102 -2
src/backend/common/vendor/atproto/AbstractATProtoApiClient.ts
··· 5 5 import { Agent, ComAtprotoRepoListRecords } from "@atproto/api"; 6 6 import { MSCache } from "../../Cache.js"; 7 7 import { UpstreamError } from "../../errors/UpstreamError.js"; 8 - import { streamBodyProgress } from "../../../utils/NetworkUtils.js"; 8 + import { isPortReachableConnect, normalizeWebAddress, streamBodyProgress } from "../../../utils/NetworkUtils.js"; 9 + import { 10 + CompositeDidDocumentResolver, 11 + CompositeHandleResolver, 12 + DohJsonHandleResolver, 13 + PlcDidDocumentResolver, 14 + WebDidDocumentResolver, 15 + WellKnownHandleResolver, 16 + } from "@atcute/identity-resolver"; 17 + import { AtprotoDid, DidDocument } from "@atproto/oauth-client-node"; 18 + import { isNodeNetworkException } from "../../errors/NodeErrors.js"; 19 + import { ATProtoUserIdentifierData } from "../../infrastructure/config/client/atproto.js"; 20 + 21 + interface HandleData { 22 + did: string 23 + pds: string 24 + } 9 25 10 26 export abstract class AbstractATProtoApiClient extends AbstractApiClient { 11 27 ··· 14 30 cache: MSCache; 15 31 16 32 constructor(name: any, config: TealClientData, options: AbstractApiOptions) { 17 - super('blueSky', name, config, options); 33 + super('atproto', name, config, options); 18 34 19 35 this.cache = getRoot().items.cache(); 20 36 } ··· 36 52 return response; 37 53 } catch (e) { 38 54 throw new UpstreamError(`Failed to list scrobble record`, { cause: e, response: 'response' in e ? e.response : undefined }); 55 + } 56 + } 57 + 58 + protected async getATProtoIdentifier(data: ATProtoUserIdentifierData): Promise<HandleData> { 59 + 60 + let hd: HandleData; 61 + hd = await this.cache.cacheAuth.get<HandleData>(`${this.name}-handleData`); 62 + if (hd !== undefined) { 63 + this.logger.debug('Found cached handle data'); 64 + return hd; 65 + } else { 66 + this.logger.debug('Handle data not cached, attempting to resolve...'); 67 + } 68 + 69 + const handleResolver = new CompositeHandleResolver({ 70 + strategy: "race", 71 + methods: { 72 + dns: new DohJsonHandleResolver({ 73 + dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 74 + }), 75 + http: new WellKnownHandleResolver(), 76 + }, 77 + }); 78 + 79 + const { 80 + did: givenDid, 81 + identifier 82 + } = data; 83 + 84 + let did: AtprotoDid = givenDid; 85 + if(did === undefined) { 86 + try { 87 + did = await handleResolver.resolve(identifier as `${string}.${string}`); 88 + this.logger.debug(`Resolved ${did}`); 89 + } catch (e) { 90 + throw new Error('Unable to resolve handle', { cause: e }); 91 + } 92 + } 93 + 94 + const docResolver = new CompositeDidDocumentResolver({ 95 + methods: { 96 + plc: new PlcDidDocumentResolver(), 97 + web: new WebDidDocumentResolver(), 98 + }, 99 + }); 100 + 101 + let doc: Awaited<ReturnType<typeof docResolver.resolve>>; 102 + try { 103 + doc = await docResolver.resolve(did); 104 + } catch (e) { 105 + throw new Error('Unable to resolve did document', { cause: e }); 106 + } 107 + if (doc.service === undefined || doc.service.length === 0) { 108 + throw new Error('did document did not return a service'); 109 + } 110 + 111 + if (typeof doc.service[0].serviceEndpoint !== 'string') { 112 + throw new Error(`Do not know how to handle this serviceEndpoint data structure!\n${JSON.stringify(doc.service[0].serviceEndpoint)}`); 113 + } 114 + hd = { did, pds: doc.service[0].serviceEndpoint }; 115 + 116 + this.cache.cacheAuth.set(`${this.name}-handleData`, hd, '1d'); 117 + return hd; 118 + } 119 + 120 + 121 + async checkPds(data: ATProtoUserIdentifierData): Promise<true> { 122 + let hd: HandleData; 123 + try { 124 + hd = await this.getATProtoIdentifier(data); 125 + } catch(e) { 126 + throw new Error('Unable to get handle data', {cause: e}); 127 + } 128 + 129 + const normal = normalizeWebAddress(hd.pds); 130 + 131 + try { 132 + await isPortReachableConnect(normal.port, {host: normal.url.hostname}); 133 + return true; 134 + } catch (e) { 135 + if(isNodeNetworkException(e)) { 136 + throw new Error('Could not communicate with PDS server', {cause: e}); 137 + } 138 + throw new Error('Unexpected error when trying to communicate with PDS server', {cause: e}); 39 139 } 40 140 } 41 141
+1 -1
src/backend/scrobblers/TealfmScrobbler.ts
··· 84 84 protected async doCheckConnection(): Promise<true | string | undefined> { 85 85 if (this.client.client instanceof ATProtoAppApiClient) { 86 86 try { 87 - return await this.client.client.checkPds(); 87 + return await this.client.client.checkPds(this.config.data); 88 88 } catch (e) { 89 89 throw e; 90 90 }
+1 -1
src/backend/sources/TealfmSource.ts
··· 72 72 protected async doCheckConnection(): Promise<true | string | undefined> { 73 73 if (this.client instanceof ATProtoAppApiClient) { 74 74 try { 75 - return await this.client.checkPds(); 75 + return await this.client.checkPds(this.config.data); 76 76 } catch (e) { 77 77 throw e; 78 78 }