feat(tealfm): Add tealfm Source

This commit is contained in:
FoxxMD 2025-11-07 17:17:50 +00:00
parent 8110e8e9fa
commit fa6ed323ff
10 changed files with 179 additions and 25 deletions

View file

@ -6,5 +6,13 @@
"identifier": "myemail@gmail.com",
"appPassword": "twog-phu7-4dhe-y4j3"
}
},
{
"name": "myTealSource",
"configureAs": "source",
"data": {
"identifier": "myemail@gmail.com",
"appPassword": "twog-phu7-4dhe-y4j3"
}
}
]

View file

@ -32,7 +32,8 @@ export type SourceType =
| 'vlc'
| 'icecast'
| 'azuracast'
| 'koito';
| 'koito'
| 'tealfm';
export const sourceTypes: SourceType[] = [
'spotify',
@ -59,7 +60,8 @@ export const sourceTypes: SourceType[] = [
'vlc',
'icecast',
'azuracast',
'koito'
'koito',
'tealfm'
];
export const isSourceType = (data: string): data is SourceType => {

View file

@ -2,10 +2,7 @@ import { RequestRetryOptions } from "../common.js"
import { CommonClientConfig, CommonClientData, CommonClientOptions } from "./index.js"
export interface TealData extends RequestRetryOptions {
}
export interface TealClientData extends TealData, CommonClientData {
/**
/**
* The base URI of the Multi-Scrobbler to use for ATProto OAuth
*
* Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol.
@ -28,9 +25,12 @@ export interface TealClientData extends TealData, CommonClientData {
appPassword?: string
}
export interface TealClientData extends TealData, CommonClientData {
}
export interface TealClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using Koito as a client
* Should always be `client` when using Tealfm as a client
*
* @default client
* @examples ["client"]
@ -40,7 +40,7 @@ export interface TealClientConfig extends CommonClientConfig {
options?: TealClientOptions
}
export interface TealClientOptions extends CommonClientOptions {
export interface TealOptions {
/** The [PDS (Personal Data Server)](https://github.com/bluesky-social/pds) to use
*
* @default "https://bsky.social"
@ -49,6 +49,11 @@ export interface TealClientOptions extends CommonClientOptions {
pds?: string
}
export interface TealClientOptions extends TealOptions,CommonClientOptions {
}
export interface TealClientAIOConfig extends TealClientConfig {
type: 'tealfm'
}

View file

@ -23,6 +23,7 @@ import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js";
import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js";
import { TealSourceAIOConfig, TealSourceConfig } from "./tealfm.js";
export type SourceConfig =
@ -53,7 +54,8 @@ export type SourceConfig =
| VLCSourceConfig
| IcecastSourceConfig
| AzuracastSourceConfig
| KoitoSourceConfig;
| KoitoSourceConfig
| TealSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@ -83,4 +85,5 @@ export type SourceAIOConfig =
| VLCSourceAIOConfig
| IcecastSourceAIOConfig
| AzuracastSourceAIOConfig
| KoitoSourceAIOConfig;
| KoitoSourceAIOConfig
| TealSourceAIOConfig;

View file

@ -0,0 +1,27 @@
import { TealData, TealOptions } from "../client/tealfm.js"
import { PollingOptions } from "../common.js"
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"
export interface TealSourceData extends TealData, CommonSourceData, PollingOptions {
}
export interface TealSourceConfig extends CommonSourceConfig {
/**
* Should always be `souce` when using Tealfm as a Source
*
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
data: TealSourceData
options?: TealSourceOptions
}
export interface TealSourceOptions extends CommonSourceOptions, TealOptions {
}
export interface TealSourceAIOConfig extends TealSourceConfig {
type: 'tealfm'
}

View file

@ -31,7 +31,7 @@ type ParsedConfig = ClientAIOConfig & ConfigMeta;
export default class ScrobbleClients {
/** @type AbstractScrobbleClient[] */
clients: (MalojaScrobbler | LastfmScrobbler | TealScrobbler)[] = [];
clients: (MalojaScrobbler | LastfmScrobbler | KoitoScrobbler | TealScrobbler)[] = [];
logger: Logger;
configDir: string;
localUrl: URL;
@ -148,7 +148,7 @@ export default class ScrobbleClients {
this.logger.error(invalidTypeMsg);
continue;
}
if(['lastfm','listenbrainz','koito','tealfm'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) {
if(['lastfm','listenbrainz','koito','tealfm'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig).configureAs === 'source')) {
this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`);
continue;
}
@ -287,8 +287,8 @@ export default class ScrobbleClients {
continue;
}
for(const [i,rawConf] of rawClientConfigs.entries()) {
if(['lastfm','listenbrainz','koito'].includes(clientType) &&
((rawConf as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig).configureAs === 'source'))
if(['lastfm','listenbrainz','koito','tealfm'].includes(clientType) &&
((rawConf as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig).configureAs === 'source'))
{
this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`);
continue;
@ -381,7 +381,7 @@ ${sources.join('\n')}`);
newClient = new KoitoScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as KoitoClientConfig, {}, notifier, this.emitter, this.logger);
break;
case 'tealfm':
newClient = new TealScrobbler(name, {...clientConfig, data: {baseUri: `${this.localUrl}/api/tealfm/${name}`, ...data}} as unknown as TealClientConfig, {}, notifier, this.emitter, this.logger);
newClient = new TealScrobbler(name, {...clientConfig, data: {...data}} as unknown as TealClientConfig, {}, notifier, this.emitter, this.logger);
break;
default:
break;

View file

@ -17,7 +17,7 @@ import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, recordToPlay
export default class TealScrobbler extends AbstractScrobbleClient {
requiresAuth = true;
requiresAuthInteraction = true;
requiresAuthInteraction = false;
declare config: TealClientConfig;
@ -29,10 +29,10 @@ export default class TealScrobbler extends AbstractScrobbleClient {
this.scrobbleDelay = 1500;
this.supportsNowPlaying = false;
if(config.data.appPassword !== undefined) {
this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger});
this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...options, logger});
this.requiresAuthInteraction = false;
} else if(config.data.baseUri !== undefined) {
this.client = new BlueSkyOauthApiClient(name, config.data, {...options, logger});
this.client = new BlueSkyOauthApiClient(name, {...config.data, pds: config.options?.pds}, {...options, logger});
} else {
throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`);
}

View file

@ -1,12 +1,9 @@
import EventEmitter from "events";
import request from "superagent";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { InternalConfig } from "../common/infrastructure/Atomic.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js";
import { MalojaApiClient } from "../common/vendor/maloja/MalojaApiClient.js";
import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";

View file

@ -69,6 +69,8 @@ import DeezerInternalSource from './DeezerInternalSource.js';
import KoitoSource from './KoitoSource.js';
import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js';
import MalojaSource from './MalojaSource.js';
import TealfmSource from './TealfmSource.js';
import { TealSourceConfig } from '../common/infrastructure/config/source/tealfm.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@ -209,6 +211,9 @@ export default class ScrobbleSources {
case 'koito':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KoitoSourceConfig");
break;
case 'tealfm':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TealSourceConfig");
break;
}
}
return this.schemaDefinitions[type];
@ -287,7 +292,7 @@ export default class ScrobbleSources {
this.logger.error(invalidMsgType);
continue;
}
if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source'))
if(['lastfm','listenbrainz','koito','tealfm'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | TealSourceConfig).configureAs !== 'source'))
{
this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`);
continue;
@ -703,8 +708,8 @@ export default class ScrobbleSources {
continue;
}
for (const [i,rawConf] of sourceConfigs.entries()) {
if(['lastfm','listenbrainz','koito','maloja'].includes(sourceType) &&
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig).configureAs !== 'source'))
if(['lastfm','listenbrainz','koito','maloja','tealfm'].includes(sourceType) &&
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig | TealSourceConfig).configureAs !== 'source'))
{
this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`);
continue;
@ -882,6 +887,9 @@ export default class ScrobbleSources {
case 'maloja':
newSource = await new MalojaSource(name, compositeConfig as MalojaSourceConfig, this.internalConfig, this.emitter);
break;
case 'tealfm':
newSource = await new TealfmSource(name, compositeConfig as TealSourceConfig, this.internalConfig, this.emitter);
break;
default:
break;
}

View file

@ -0,0 +1,104 @@
import EventEmitter from "events";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { AbstractBlueSkyApiClient, listRecordToPlay, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js";
import { TealSourceConfig } from "../common/infrastructure/config/source/tealfm.js";
import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js";
import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js";
import { ListRecord, ScrobbleRecord } from "../common/infrastructure/config/client/tealfm.js";
export default class TealfmSource extends MemorySource {
client: AbstractBlueSkyApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
declare config: TealSourceConfig;
constructor(name: any, config: TealSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data: {
interval = 15,
maxInterval = 60,
...restData
} = {}
} = config;
super('tealfm', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter);
this.canPoll = true;
this.canBacklog = true;
if(config.data.appPassword !== undefined) {
this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger});
this.requiresAuthInteraction = false;
} else if(config.data.baseUri !== undefined) {
this.client = new BlueSkyOauthApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger});
} else {
throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`);
}
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.supportsUpstreamRecentlyPlayed = true
this.SCROBBLE_BACKLOG_COUNT = 20;
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return recordToPlay(obj); }
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
identifier,
} = {}
} = this.config;
if (identifier === undefined) {
throw new Error('Must provide an identifier');
}
this.client.initClient();
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
return true;
}
doAuthentication = async () => {
try {
const sessionRes = await this.client.restoreSession();
if(sessionRes) {
return true;
}
if(this.client instanceof BlueSkyAppApiClient) {
return await this.client.appLogin();
}
} catch (e) {
if(isNodeNetworkException(e)) {
this.logger.error('Could not communicate with ATProto API');
}
throw e;
}
}
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
let list: ListRecord<ScrobbleRecord>[];
try {
list = await this.client.listScrobbleRecord(limit)
} catch (e) {
throw new Error('Error occurred while trying to fetch records', {cause: e});
}
this.processRecentPlays([]);
const plays = list.map(x => listRecordToPlay(x));
return plays;
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
try {
return await this.getRecentlyPlayed(options);
} catch (e) {
throw e;
}
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
}