diff --git a/config/tealfm.json.example b/config/tealfm.json.example index 8d482858..d6ff53fa 100644 --- a/config/tealfm.json.example +++ b/config/tealfm.json.example @@ -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" + } } ] \ No newline at end of file diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 21e17c36..675820b2 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -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 => { diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index 2d8e37da..1ffea0d6 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -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' } diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 7958c99b..de136231 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -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; diff --git a/src/backend/common/infrastructure/config/source/tealfm.ts b/src/backend/common/infrastructure/config/source/tealfm.ts new file mode 100644 index 00000000..34701de3 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/tealfm.ts @@ -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' +} \ No newline at end of file diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index d7f18d4d..516ff152 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -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; diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index b2c9783c..fe658687 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -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!`); } diff --git a/src/backend/sources/MalojaSource.ts b/src/backend/sources/MalojaSource.ts index f0e32b19..bab09c93 100644 --- a/src/backend/sources/MalojaSource.ts +++ b/src/backend/sources/MalojaSource.ts @@ -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"; diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index ce43ad11..0820d23d 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -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; } diff --git a/src/backend/sources/TealfmSource.ts b/src/backend/sources/TealfmSource.ts new file mode 100644 index 00000000..5e1e8c2a --- /dev/null +++ b/src/backend/sources/TealfmSource.ts @@ -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 { + const { + data: { + identifier, + } = {} + } = this.config; + if (identifier === undefined) { + throw new Error('Must provide an identifier'); + } + this.client.initClient(); + return true; + } + + protected async doCheckConnection(): Promise { + 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[]; + 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 => { + try { + return await this.getRecentlyPlayed(options); + } catch (e) { + throw e; + } + } + + protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) +}