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

feat(tealfm): Add tealfm Source

FoxxMD (Nov 7, 2025, 5:17 PM UTC) fa6ed323 8110e8e9

+179 -25
+8
config/tealfm.json.example
··· 6 6 "identifier": "myemail@gmail.com", 7 7 "appPassword": "twog-phu7-4dhe-y4j3" 8 8 } 9 + }, 10 + { 11 + "name": "myTealSource", 12 + "configureAs": "source", 13 + "data": { 14 + "identifier": "myemail@gmail.com", 15 + "appPassword": "twog-phu7-4dhe-y4j3" 16 + } 9 17 } 10 18 ]
+5 -5
src/backend/scrobblers/ScrobbleClients.ts
··· 31 31 export default class ScrobbleClients { 32 32 33 33 /** @type AbstractScrobbleClient[] */ 34 - clients: (MalojaScrobbler | LastfmScrobbler | TealScrobbler)[] = []; 34 + clients: (MalojaScrobbler | LastfmScrobbler | KoitoScrobbler | TealScrobbler)[] = []; 35 35 logger: Logger; 36 36 configDir: string; 37 37 localUrl: URL; ··· 148 148 this.logger.error(invalidTypeMsg); 149 149 continue; 150 150 } 151 - if(['lastfm','listenbrainz','koito','tealfm'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) { 151 + if(['lastfm','listenbrainz','koito','tealfm'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig).configureAs === 'source')) { 152 152 this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`); 153 153 continue; 154 154 } ··· 287 287 continue; 288 288 } 289 289 for(const [i,rawConf] of rawClientConfigs.entries()) { 290 - if(['lastfm','listenbrainz','koito'].includes(clientType) && 291 - ((rawConf as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig).configureAs === 'source')) 290 + if(['lastfm','listenbrainz','koito','tealfm'].includes(clientType) && 291 + ((rawConf as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig).configureAs === 'source')) 292 292 { 293 293 this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`); 294 294 continue; ··· 381 381 newClient = new KoitoScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as KoitoClientConfig, {}, notifier, this.emitter, this.logger); 382 382 break; 383 383 case 'tealfm': 384 - newClient = new TealScrobbler(name, {...clientConfig, data: {baseUri: `${this.localUrl}/api/tealfm/${name}`, ...data}} as unknown as TealClientConfig, {}, notifier, this.emitter, this.logger); 384 + newClient = new TealScrobbler(name, {...clientConfig, data: {...data}} as unknown as TealClientConfig, {}, notifier, this.emitter, this.logger); 385 385 break; 386 386 default: 387 387 break;
+3 -3
src/backend/scrobblers/TealfmScrobbler.ts
··· 17 17 export default class TealScrobbler extends AbstractScrobbleClient { 18 18 19 19 requiresAuth = true; 20 - requiresAuthInteraction = true; 20 + requiresAuthInteraction = false; 21 21 22 22 declare config: TealClientConfig; 23 23 ··· 29 29 this.scrobbleDelay = 1500; 30 30 this.supportsNowPlaying = false; 31 31 if(config.data.appPassword !== undefined) { 32 - this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger}); 32 + this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...options, logger}); 33 33 this.requiresAuthInteraction = false; 34 34 } else if(config.data.baseUri !== undefined) { 35 - this.client = new BlueSkyOauthApiClient(name, config.data, {...options, logger}); 35 + this.client = new BlueSkyOauthApiClient(name, {...config.data, pds: config.options?.pds}, {...options, logger}); 36 36 } else { 37 37 throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`); 38 38 }
+1 -4
src/backend/sources/MalojaSource.ts
··· 1 1 import EventEmitter from "events"; 2 - import request from "superagent"; 3 2 import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 4 3 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 5 - import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 4 + import { InternalConfig } from "../common/infrastructure/Atomic.js"; 6 5 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 7 6 import MemorySource from "./MemorySource.js"; 8 - import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js"; 9 - import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js"; 10 7 import { MalojaApiClient } from "../common/vendor/maloja/MalojaApiClient.js"; 11 8 import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js"; 12 9
+11 -3
src/backend/sources/ScrobbleSources.ts
··· 69 69 import KoitoSource from './KoitoSource.js'; 70 70 import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js'; 71 71 import MalojaSource from './MalojaSource.js'; 72 + import TealfmSource from './TealfmSource.js'; 73 + import { TealSourceConfig } from '../common/infrastructure/config/source/tealfm.js'; 72 74 73 75 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 74 76 ··· 209 211 case 'koito': 210 212 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KoitoSourceConfig"); 211 213 break; 214 + case 'tealfm': 215 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TealSourceConfig"); 216 + break; 212 217 } 213 218 } 214 219 return this.schemaDefinitions[type]; ··· 287 292 this.logger.error(invalidMsgType); 288 293 continue; 289 294 } 290 - if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source')) 295 + if(['lastfm','listenbrainz','koito','tealfm'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | TealSourceConfig).configureAs !== 'source')) 291 296 { 292 297 this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`); 293 298 continue; ··· 703 708 continue; 704 709 } 705 710 for (const [i,rawConf] of sourceConfigs.entries()) { 706 - if(['lastfm','listenbrainz','koito','maloja'].includes(sourceType) && 707 - ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig).configureAs !== 'source')) 711 + if(['lastfm','listenbrainz','koito','maloja','tealfm'].includes(sourceType) && 712 + ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig | TealSourceConfig).configureAs !== 'source')) 708 713 { 709 714 this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`); 710 715 continue; ··· 881 886 break; 882 887 case 'maloja': 883 888 newSource = await new MalojaSource(name, compositeConfig as MalojaSourceConfig, this.internalConfig, this.emitter); 889 + break; 890 + case 'tealfm': 891 + newSource = await new TealfmSource(name, compositeConfig as TealSourceConfig, this.internalConfig, this.emitter); 884 892 break; 885 893 default: 886 894 break;
+104
src/backend/sources/TealfmSource.ts
··· 1 + import EventEmitter from "events"; 2 + import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 3 + import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 4 + import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 5 + import { RecentlyPlayedOptions } from "./AbstractSource.js"; 6 + import MemorySource from "./MemorySource.js"; 7 + import { AbstractBlueSkyApiClient, listRecordToPlay, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; 8 + import { TealSourceConfig } from "../common/infrastructure/config/source/tealfm.js"; 9 + import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; 10 + import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; 11 + import { ListRecord, ScrobbleRecord } from "../common/infrastructure/config/client/tealfm.js"; 12 + 13 + export default class TealfmSource extends MemorySource { 14 + 15 + client: AbstractBlueSkyApiClient; 16 + requiresAuth = true; 17 + requiresAuthInteraction = false; 18 + 19 + declare config: TealSourceConfig; 20 + 21 + constructor(name: any, config: TealSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 22 + const { 23 + data: { 24 + interval = 15, 25 + maxInterval = 60, 26 + ...restData 27 + } = {} 28 + } = config; 29 + super('tealfm', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter); 30 + this.canPoll = true; 31 + this.canBacklog = true; 32 + if(config.data.appPassword !== undefined) { 33 + this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger}); 34 + this.requiresAuthInteraction = false; 35 + } else if(config.data.baseUri !== undefined) { 36 + this.client = new BlueSkyOauthApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger}); 37 + } else { 38 + throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`); 39 + } 40 + this.playerSourceOfTruth = SOURCE_SOT.HISTORY; 41 + this.supportsUpstreamRecentlyPlayed = true 42 + this.SCROBBLE_BACKLOG_COUNT = 20; 43 + } 44 + 45 + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return recordToPlay(obj); } 46 + 47 + protected async doBuildInitData(): Promise<true | string | undefined> { 48 + const { 49 + data: { 50 + identifier, 51 + } = {} 52 + } = this.config; 53 + if (identifier === undefined) { 54 + throw new Error('Must provide an identifier'); 55 + } 56 + this.client.initClient(); 57 + return true; 58 + } 59 + 60 + protected async doCheckConnection(): Promise<true | string | undefined> { 61 + return true; 62 + } 63 + 64 + doAuthentication = async () => { 65 + try { 66 + const sessionRes = await this.client.restoreSession(); 67 + if(sessionRes) { 68 + return true; 69 + } 70 + if(this.client instanceof BlueSkyAppApiClient) { 71 + return await this.client.appLogin(); 72 + } 73 + } catch (e) { 74 + if(isNodeNetworkException(e)) { 75 + this.logger.error('Could not communicate with ATProto API'); 76 + } 77 + throw e; 78 + } 79 + } 80 + 81 + 82 + getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => { 83 + const {limit = 20} = options; 84 + let list: ListRecord<ScrobbleRecord>[]; 85 + try { 86 + list = await this.client.listScrobbleRecord(limit) 87 + } catch (e) { 88 + throw new Error('Error occurred while trying to fetch records', {cause: e}); 89 + } 90 + this.processRecentPlays([]); 91 + const plays = list.map(x => listRecordToPlay(x)); 92 + return plays; 93 + } 94 + 95 + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 96 + try { 97 + return await this.getRecentlyPlayed(options); 98 + } catch (e) { 99 + throw e; 100 + } 101 + } 102 + 103 + protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) 104 + }
+4 -2
src/backend/common/infrastructure/Atomic.ts
··· 32 32 | 'vlc' 33 33 | 'icecast' 34 34 | 'azuracast' 35 - | 'koito'; 35 + | 'koito' 36 + | 'tealfm'; 36 37 37 38 export const sourceTypes: SourceType[] = [ 38 39 'spotify', ··· 59 60 'vlc', 60 61 'icecast', 61 62 'azuracast', 62 - 'koito' 63 + 'koito', 64 + 'tealfm' 63 65 ]; 64 66 65 67 export const isSourceType = (data: string): data is SourceType => {
+11 -6
src/backend/common/infrastructure/config/client/tealfm.ts
··· 2 2 import { CommonClientConfig, CommonClientData, CommonClientOptions } from "./index.js" 3 3 4 4 export interface TealData extends RequestRetryOptions { 5 - } 6 - 7 - export interface TealClientData extends TealData, CommonClientData { 8 - /** 5 + /** 9 6 * The base URI of the Multi-Scrobbler to use for ATProto OAuth 10 7 * 11 8 * Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol. ··· 28 25 appPassword?: string 29 26 } 30 27 28 + export interface TealClientData extends TealData, CommonClientData { 29 + 30 + } 31 31 export interface TealClientConfig extends CommonClientConfig { 32 32 /** 33 - * Should always be `client` when using Koito as a client 33 + * Should always be `client` when using Tealfm as a client 34 34 * 35 35 * @default client 36 36 * @examples ["client"] ··· 40 40 options?: TealClientOptions 41 41 } 42 42 43 - export interface TealClientOptions extends CommonClientOptions { 43 + export interface TealOptions { 44 44 /** The [PDS (Personal Data Server)](https://github.com/bluesky-social/pds) to use 45 45 * 46 46 * @default "https://bsky.social" 47 47 * @examples ["https://bsky.social"] 48 48 */ 49 49 pds?: string 50 + } 51 + 52 + 53 + export interface TealClientOptions extends TealOptions,CommonClientOptions { 54 + 50 55 } 51 56 52 57 export interface TealClientAIOConfig extends TealClientConfig {
+5 -2
src/backend/common/infrastructure/config/source/sources.ts
··· 23 23 import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js"; 24 24 import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js"; 25 25 import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js"; 26 + import { TealSourceAIOConfig, TealSourceConfig } from "./tealfm.js"; 26 27 27 28 28 29 export type SourceConfig = ··· 53 54 | VLCSourceConfig 54 55 | IcecastSourceConfig 55 56 | AzuracastSourceConfig 56 - | KoitoSourceConfig; 57 + | KoitoSourceConfig 58 + | TealSourceConfig; 57 59 58 60 export type SourceAIOConfig = 59 61 SpotifySourceAIOConfig ··· 83 85 | VLCSourceAIOConfig 84 86 | IcecastSourceAIOConfig 85 87 | AzuracastSourceAIOConfig 86 - | KoitoSourceAIOConfig; 88 + | KoitoSourceAIOConfig 89 + | TealSourceAIOConfig;
+27
src/backend/common/infrastructure/config/source/tealfm.ts
··· 1 + import { TealData, TealOptions } from "../client/tealfm.js" 2 + import { PollingOptions } from "../common.js" 3 + import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js" 4 + 5 + 6 + export interface TealSourceData extends TealData, CommonSourceData, PollingOptions { 7 + 8 + } 9 + 10 + export interface TealSourceConfig extends CommonSourceConfig { 11 + /** 12 + * Should always be `souce` when using Tealfm as a Source 13 + * 14 + * @default source 15 + * @examples ["source"] 16 + * */ 17 + configureAs?: 'source' 18 + data: TealSourceData 19 + options?: TealSourceOptions 20 + } 21 + 22 + export interface TealSourceOptions extends CommonSourceOptions, TealOptions { 23 + } 24 + 25 + export interface TealSourceAIOConfig extends TealSourceConfig { 26 + type: 'tealfm' 27 + }