Something went wrong. Try again.
[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
Something went wrong. Try again.
30 kB · 709 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710import dayjs, { type Dayjs } from "dayjs";import type EventEmitter from "events";import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type PlayObjectMinimal} from "../../core/Atomic.ts";import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts";import type {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.ts";import { Innertube, UniversalCache, Parser, YTNodes, type ApiResponse, type IBrowseResponse, Log, type SessionOptions } from 'youtubei.js';import { type GenerateAuthUrlOpts, OAuth2Client } from 'google-auth-library';import {resolve} from 'path';import { isDebugMode, parseBool, sleep } from "../utils.ts";import { formatNumber } from '../../core/DataUtils.ts';import { getPlaysDiff, humanReadableDiff, type PlayOrderChangeType, type PlayOrderConsistencyResults, playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent} from "../utils/PlayComparisonUtils.ts";import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts";import { artistNamesToCredits, buildTrackString, truncateStringToLength } from "../../core/StringUtils.ts";import { joinedUrl } from "../utils/NetworkUtils.ts";import { todayAwareFormat } from "../../core/TimeUtils.ts";import { parseArrayFromMaybeString, parseArtistCredits } from "../utils/StringUtils.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { FixedSizeList } from "fixed-size-list";
export interface HistoryIngressResult { plays: PlayObject[], consistent: boolean, diffResults?: PlayOrderConsistencyResults<PlayOrderChangeType>, diffType?: 'bump' | 'added', reason?: string}
export const ytiHistoryResponseToListItems = (res: ApiResponse): YTNodes.MusicResponsiveListItem[] => { const page = Parser.parseResponse<IBrowseResponse>(res.data); const items = page.contents_memo.getType(YTNodes.MusicResponsiveListItem); return Array.from(items);}
const maybeJsonErrorInfo = (err: Error): object | string | undefined => { if('info' in err) { try { return JSON.parse(err.info as string); } catch (e) { return err.info as string; } } return undefined;}
const loggedErrorExtra = (err: Error): object | undefined => { const maybeInfo = maybeJsonErrorInfo(err); if(maybeInfo === undefined) { return undefined; } if(typeof maybeInfo === 'string') { return {apiResponse: maybeInfo}; } return maybeInfo;}
export const ytiHistoryResponseFromShelfToPlays = (res: ApiResponse, options: {newFromSource?: boolean} = {}): PlayObject[] => { const page = Parser.parseResponse<IBrowseResponse>(res.data); const items: PlayObject[] = []; const shelves = page.contents_memo.getType(YTNodes.MusicShelf); shelves.forEach((shelf) => { shelf.contents.forEach((listItem) => { items.push(YTMusicSource.formatPlayObj(listItem, {shelf: shelf.title.text, newFromSource: options.newFromSource ?? false})); }); }); return items;}
const DEFAULT_SCOPES = [ "http://gdata.youtube.com", "https://www.googleapis.com/auth/youtube", "https://www.googleapis.com/auth/youtube.force-ssl", "https://www.googleapis.com/auth/youtube-paid-content", "https://www.googleapis.com/auth/accounts.reauth",];
const VALID_SCOPES = [ "https://www.googleapis.com/auth/youtube", "https://www.googleapis.com/auth/youtube.force-ssl", "https://www.googleapis.com/auth/youtube-paid-content",]
const getGoogleOauthOpts = (): GenerateAuthUrlOpts => { let scopes: string[]; const userInput = parseArrayFromMaybeString(process.env.YTM_SCOPES); if (userInput.length > 0) { scopes = userInput.map(x => { if (x.toLocaleLowerCase() === 'default') { return DEFAULT_SCOPES; } else if (x.toLocaleLowerCase() === 'valid') { return VALID_SCOPES; } return x; }).flat(1); } else { scopes = DEFAULT_SCOPES; }
return { access_type: 'offline', scope: scopes, include_granted_scopes: true, prompt: 'consent', };}
export default class YTMusicSource extends AbstractSource {
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; requiresAuth = true; requiresAuthInteraction = true;
declare config: YTMusicSourceConfig
recentlyPlayed: PlayObject[] = []; transientDiscovered: FixedSizeList<PlayObject> = new FixedSizeList<PlayObject>(200);
yti: Innertube; userCode?: string; verificationUrl?: string; redirectUri?: string; oauthClient?: OAuth2Client;
workingCredsPath: string;
recentChangedHistoryResponses: {ts: Dayjs, plays: PlayObject[]}[] = [];
constructor(name: string, config: YTMusicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('ytmusic', name, config, internal, emitter); this.canPoll = true; this.supportsUpstreamRecentlyPlayed = true; this.workingCredsPath = resolve(this.configDir, `yti-${this.name}`);
const { logDiff, ...rest } = this.config.options || {};
let diffVal = logDiff;
if(diffVal === undefined) { const diffEnv = process.env.YTM_LOG_DIFF; if(diffEnv !== undefined) { diffVal = parseBool(diffEnv); this.config.options = {...rest, logDiff: diffVal}; } }
this.emitter.on('discovered', (play) => { this.transientDiscovered.add(play); }) }
public additionalApiData(): Record<string, any> { const data: Record<string, any> = {}; if(this.userCode !== undefined) { data.userCode = this.userCode; } return data; }
protected configureYTIEvents() { this.yti.session.on('update-credentials', async ({ credentials }) => { if(this.config.options?.logAuth) { this.logger.debug(credentials, 'Credentials updated'); } else { this.logger.debug('Credentials updated'); } await this.yti.session.oauth.cacheCredentials(); }); this.yti.session.on('auth-pending', async (data) => { if(this.oauthClient === undefined) { this.userCode = data.user_code; this.verificationUrl = data.verification_url; } }); this.yti.session.on('auth-error', async (data) => { this.logger.error(new Error('YTM Authentication error', {cause: data})); }); this.yti.session.on('auth', async ({ credentials }) => { if(this.config.options?.logAuth) { this.logger.debug(credentials, 'Auth success'); } else { this.logger.debug('Auth success'); } this.userCode = undefined; this.authed = true; await this.yti.session.oauth.cacheCredentials(); }); if(isDebugMode()) { Log.setLevel(Log.Level.DEBUG); } }
protected configureCustomOauth() { this.redirectUri = this.config.data?.redirectUri; if(this.redirectUri === undefined) { const u = joinedUrl(this.localUrl, 'api/ytmusic/callback'); u.searchParams.append('name', this.name); this.redirectUri = u.toString(); } else { // verify custom URI has required parts let u: URL; try { u = new URL(this.redirectUri); } catch(e) { throw new Error(`custom redirectUri '${this.redirectUri}' could not be parsed as a URL`, {cause: e}); }
if(!u.protocol.includes('http')) { throw new Error(`Custom redirectUri '${this.redirectUri}' is missing protocol! Must start with 'http' or 'https'`); } if(!u.pathname.includes('api')) { this.logger.warn(`Custom redirectUri '${this.redirectUri}' does not contain 'api' in path! Unless you know what you are doing with redirects this will likely cause authentication to fail.`); } if(null === u.pathname.match(/ytmusic\/callback$/)) { throw new Error(`Custom redirectUri '${this.redirectUri}' must end in 'ytmusic/callback' before querystring!`); } if(!u.searchParams.has('name')) { throw new Error(`Custom redirectUri '${this.redirectUri}' is missing 'name' in querystring! EX ?name=${this.name}`); } const nameVal = u.searchParams.get('name'); if(nameVal !== this.name) { throw new Error(`Custom redirectUri '${this.redirectUri}' has wrong value '${nameVal}' for 'name' key in querystring. Must match Source name, case-sensitive -- EX ?name=${this.name}`); } }
this.oauthClient = new OAuth2Client({ clientId: this.config.data.clientId, clientSecret: this.config.data.clientSecret, redirectUri: this.redirectUri, });
const scopeOpts = getGoogleOauthOpts(); this.logger.debug(`Using scopes:\n${(scopeOpts.scope as string[]).join('\n')}`) const authorizationUrl = this.oauthClient.generateAuthUrl(getGoogleOauthOpts()); this.verificationUrl = authorizationUrl; }
protected async doBuildInitData(): Promise<true | string | undefined> { const { cookie, // we accept *any* SessionsOptions the user passes // into innertubeOptions so that they can tailor yti however they want // // but in docs we only explicitly document things that seem relevant for MS // to reduce noise in file/aio schema explorer innertubeOptions = {}, } = this.config.data || {}; this.yti = await Innertube.create({ ...(innertubeOptions as SessionOptions), cookie, cache: new UniversalCache(true, this.workingCredsPath) });
if (this.config.data.clientId !== undefined && this.config.data.clientSecret !== undefined) { try { this.configureCustomOauth(); } catch (e) { throw new Error('Unable to build custom OAuth Client', { cause: e }); } this.logger.info(`Will use custom OAuth Client:Client ID : ${truncateStringToLength(10)(this.config.data.clientId)}Client Secret : ${truncateStringToLength(10)(this.config.data.clientSecret)}Redirect URI : ${this.redirectUri}`); } else if (this.config.data.clientId !== undefined || this.config.data.clientSecret !== undefined) { const missing = this.config.data.clientId !== undefined ? 'clientSecret' : 'clientId'; throw new Error(`It looks like you tried to configure a custom OAuth Client but are missing '${missing}'! Cannot build client.`); } else if (cookie !== undefined) { this.logger.info(`Will use cookie '${truncateStringToLength(10)(cookie)}' for auth`); } else { this.logger.warn('You have not provided a cookie or custom OAuth client for authorization. MS will use the fallback YoutubeTV auth but this will likely NOT provide access to Youtube Music history!! You should use one of the other methods.'); }
this.configureYTIEvents();
return true; }
reauthenticate = async () => { try { await this.tryStopPolling('Reauthenticating yt'); } catch (e) { this.logger.warn(new Error('Failed to stop polling but will try to reauth anyway', {cause: e})); } if(this.authed) { await this.clearCredentials(); this.authed = false; await this.testAuth(); } }
clearCredentials = async () => { if(this.yti.session.logged_in) { await this.yti.session.signOut(); } }
async handleAuthCodeCallback(obj: Record<string, any>): Promise<any> { if (obj.code === undefined) { this.logger.error(`Authorization callback did not contain 'code' in URL`); return false; }
const { tokens } = await this.oauthClient.getToken(obj.code as string);
if (tokens.access_token && tokens.refresh_token && tokens.expiry_date) { await this.yti.session.signIn({ access_token: tokens.access_token, refresh_token: tokens.refresh_token, expiry_date: new Date(tokens.expiry_date).toISOString(), client: { client_id: this.config.data.clientId, client_secret: this.config.data.clientSecret } }); this.authed = true; this.verificationUrl = undefined; await this.yti.session.oauth.cacheCredentials(); return true; } else { this.logger.error(`Token data did not return all required properties.`); return tokens; } }
doAuthentication = async () => { try { if (this.config.data.cookie !== undefined) { try { await this.yti.account.getInfo() this.authed = true; } catch (e) { const info = loggedErrorExtra(e); if (info !== undefined) { this.logger.error(info, 'Additional API response details') } throw new Error('Cookie-based authentication failed. Try recreating cookie or using custom OAuth Client', { cause: e }); } } else { await Promise.race([ sleep(1000), this.yti.session.signIn() ]); if (this.authed === false) {
if(this.oauthClient !== undefined) { throw new Error(`Sign in using the authentication link on the dashboard or ${this.verificationUrl}`); } else { throw new Error(`Sign in with the code '${this.userCode}' using the authentication link on the dashboard or ${this.verificationUrl}`) }
}
try { await this.yti.account.getInfo() } catch (e) { const info = loggedErrorExtra(e); if (info !== undefined) { this.logger.error(info, 'Additional API response details') } throw new Error('Credentials exist but API calls are failing. Try re-authenticating?', { cause: e }); } }
return true; } catch (e) { throw e; } }
static formatPlayObj(obj: YTNodes.MusicResponsiveListItem, options: FormatPlayObjectOptions = {}): PlayObject { const {newFromSource = false, shelf = undefined} = options; const { id, title, album: albumData, artists: artistsData, authors: authorData, duration: dur, // string timestamp } = obj;
let artists: string[] = [], album = undefined, duration = undefined; if(artistsData !== undefined) { artists = artistsData.map(x => x.name) as string[]; } else if(authorData !== undefined) { artists = authorData.map(x => x.name) as string[]; } if(artists.length === 0 && obj.flex_columns.at(1)?.title?.text !== undefined) { // if YTM doesn't have an endpoint (page) for an artist (combined) then YouTube.js doesn't include it // in the music shelf object created from parsing data // BUT YouTube.js does expose the raw data so we can try to recover artists from plain text // https://github.com/LuanRT/YouTube.js/issues/381 const credits = parseArtistCredits(obj.flex_columns.at(1)?.title?.text); if(credits !== undefined) { // try to be clever artists.push(credits.primary); if(credits.secondary !== undefined) { const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== ''); if(nonEmptyArtists.length > 0) { artists = [...artists, ...nonEmptyArtists]; } } } else { artists = [obj.flex_columns.at(1)?.title?.text]; } }
let albumArtists: string[] = []; if(artistsData !== undefined && authorData !== undefined) { albumArtists = authorData.map(x => x.name) as string[]; } if(albumData !== undefined) { album = albumData.name; } if(album === undefined && obj.flex_columns.at(2)?.title?.text !== undefined) { album = obj.flex_columns.at(2)?.title?.text; } if(dur!== undefined) { const durObj = dayjs.duration(dur.seconds, 's') duration = durObj.asSeconds(); } const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(artists), albumArtists: artistNamesToCredits(albumArtists), album, track: title, duration, // if object is new from source then we know we've picked it up AFTER we started polling so it can't be older than 1 minute (default polling interval) playDate: newFromSource ? dayjs().startOf('minute') : undefined, }, meta: { source: 'YTMusic', musicService: 'Youtube Music', trackId: id, newFromSource, comment: shelf } } return baseFormatPlayObj(obj, play); }
recentlyPlayedTrackIsValid = (playObj: PlayObject) => playObj.meta.newFromSource
protected getLibraryHistory = async (): Promise<ApiResponse> => { // internally for this call YT returns a *list* of playlists with decreasing granularity from most recent to least recent like this: // * Today // * Yesterday // * .... // * January 2023 // // the playlist returned can therefore change abruptly IE MS started yesterday and new music listened to today -> "today" playlist is cleared try { const res = await this.yti.actions.execute('/browse', { browse_id: 'FEmusic_history', client: 'YTMUSIC' }); return res; } catch (e) { const info = loggedErrorExtra(e); if(info !== undefined) { this.logger.error(info, 'Additional API response details') } throw e; } }
protected getLibraryHistoryPlaylists = async (): Promise<PlayObject[]> => { // internally for this call YT returns a *list* of playlists with decreasing granularity from most recent to least recent like this: // * Today // * Yesterday // * .... // * January 2023 // // the playlist returned can therefore change abruptly IE MS started yesterday and new music listened to today -> "today" playlist is cleared try { const res = await this.getLibraryHistory(); return ytiHistoryResponseFromShelfToPlays(res); } catch (e) { const info = loggedErrorExtra(e); if(info !== undefined) { this.logger.error(info, 'Additional API response details') } throw e; } }
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { return (await this.getLibraryHistoryPlaylists()).slice(0, 100); }
/** * Get the last 20 recently played tracks * */ getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { const { display = false } = options;
let playlistDetail: ApiResponse; try { playlistDetail = await this.getLibraryHistory(); } catch (e) { throw e; }
const listPlays = ytiHistoryResponseFromShelfToPlays(playlistDetail); return this.parseRecentAgainstResponse(listPlays).plays; }
getIncomingHistoryConsistencyResult = (plays: PlayObject[]): HistoryIngressResult => { const results: HistoryIngressResult = { plays: [], consistent: true } if(playsAreSortConsistent(this.recentlyPlayed, plays)) { return {plays: [], consistent: true}; }
let warnMsg: string; let diffResults: PlayOrderConsistencyResults<PlayOrderChangeType>; let diffType: 'bump' | 'added'; diffResults = playsAreBumpedOnly(this.recentlyPlayed, plays); if(diffResults[0] === true) { results.diffType = 'bump'; if(diffResults[2] !== 'prepend') { results.consistent = false; results.reason = `(Bump Plays Detected) Previously seen YTM history was bumped in an unexpected way (${diffResults[2]}), resetting history to new list`; } else { results.plays = [...diffResults[1]].reverse(); } } else { diffResults = playsAreAddedOnly(this.recentlyPlayed, plays); if(diffResults[0] === true) { results.diffType = 'added'; if(diffResults[2] !== 'prepend') { results.consistent = false; results.reason = `(Add Plays Detected) New tracks were added to YTM history in an unexpected way (${diffResults[2]}), resetting watched history to new list`; } else { const revertedToRecent = this.recentChangedHistoryResponses.findIndex(x => playsAreSortConsistent(x.plays, plays)); if(revertedToRecent !== -1) { results.consistent = false; results.reason = `(Add Plays Detected) YTM History has exact order as another recent response *where history was changed* (${revertedToRecent + 1} ago @ ${todayAwareFormat(this.recentChangedHistoryResponses[revertedToRecent].ts)}) which means last history (n - 1) was probably out of date. Resetting history to current list and NOT ADDING new tracks since we probably already discovered them earlier.` } else { results.plays = [...diffResults[1]].reverse(); } } } else { results.consistent = false; results.reason = 'YTM History returned temporally inconsistent order, resetting history to new list.'; } } results.diffResults = diffResults;
return results; }
parseRecentAgainstResponse = (responsePlays: PlayObject[]): HistoryIngressResult => {
//let newPlays: PlayObject[] = []; let results: HistoryIngressResult = { plays: [], consistent: true }
const plays = responsePlays.slice(0, 20); if(this.polling === false) { results.plays = plays; results.plays = results.plays.map((x, index) => ({ data: { ...x.data, playDate: dayjs().startOf('minute').add(index + 1, 's') }, meta: { ...x.meta, newFromSource: true } })); } else {
const cResults = this.getIncomingHistoryConsistencyResult(plays);
const { reason, plays: newPlays, consistent, diffResults, diffType } = cResults;
results = cResults;
if(consistent && newPlays.length > 1) { const interimPlays = newPlays.slice(0, newPlays.length - 1); // check enough time has passed since last discovery const discovered = this.transientDiscovered.data; if(discovered.length > 0) { const lastDiscovered = discovered[0].data.playDate; // the assumption in behavior is that user skips 1 or more tracks which then get recorded to YTM history // eventually, they land on a track they want to listen to which is the current (newest) track in history // -- so check duration from newest to oldest and subtract time since last discovered, *ignoring* the newest track since that is the one being played const reversed = [...interimPlays].reverse(); let timeRemaining = dayjs().diff(lastDiscovered, 'second'); const durationValidTracks: PlayObject[] = []; const durationLog: string[] = []; for(const play of reversed) { const shortIdentifier = buildTrackString(play, {include: ['track']}); if(play.data.duration === undefined) { // allow any tracks without duration, i guess? durationValidTracks.push(play); durationLog.push(`${shortIdentifier} => no duration, will allow`) continue; } if(timeRemaining <= 0) { durationLog.push(`${shortIdentifier} => Not enough time remaining!`); continue; } const newRemaining = timeRemaining - (play.data.duration * 0.5); if(newRemaining > 0) { durationValidTracks.push(play); durationLog.push(`${shortIdentifier} (50% of ${play.data.duration}s) => OK! ${timeRemaining} - ${formatNumber(play.data.duration * 0.5, {toFixed: 0})} = ${newRemaining}s remaining since last discovery`) } else { durationLog.push(`${shortIdentifier} (50% of ${play.data.duration}s) => Not OK! ${timeRemaining} - ${formatNumber(play.data.duration * 0.5, {toFixed: 0})} = ${newRemaining}s remaining since last discovery`); } timeRemaining = newRemaining; } this.logger.verbose(`More than one track found, using time since last discovered track (${dayjs().diff(lastDiscovered, 'second')}s) as guidepost for if n+1 earlier tracks could have passed 50% duration listened. Results:${durationLog.join('\n')}`); if(durationValidTracks.length === 0) { results.plays = [results.plays[results.plays.length - 1]] } else { const correctOrder = [...durationValidTracks].reverse(); results.plays = [...correctOrder, results.plays[results.plays.length - 1]]; } } else { this.logger.verbose('No existing tracks discovered, could not determine if enough time has passed to reasonably scrobble new tracks.'); } }
if(!consistent || (newPlays.length > 0 && (this.config.options?.logDiff === true || isDebugMode()))) { const playsDiff = getPlaysDiff(this.recentlyPlayed, plays) const humanDiff = humanReadableDiff(this.recentlyPlayed, plays, playsDiff); const diffMsg = `Changes from last seen list detected as ${diffType} type:${humanDiff}`; if(reason !== undefined) { this.logger.warn(reason); this.logger.warn(diffMsg); } else { this.logger.verbose(diffMsg); } } let durSinceNow = 0; const now = dayjs();
const rrPlays = results.plays.reduceRight((acc, curr) => { const durDatedPlay = { data: { ...curr.data, playDate: durSinceNow === 0 ? now : now.subtract(durSinceNow, 'seconds'), }, meta: { ...curr.meta, newFromSource: true } } durSinceNow += curr.data.duration ?? 1; return [durDatedPlay, ...acc]; }, []);
results.plays = rrPlays }
this.recentlyPlayed = plays;
if(results.plays.length > 0) { this.recentChangedHistoryResponses = [{plays, ts: dayjs()}, ...this.recentChangedHistoryResponses.slice(0, 3)] }
return results; }
onPollPostAuthCheck = async () => { if(!this.polling) { this.logger.verbose('Hydrating initial recently played tracks for reference.'); const referencePlays = await this.getRecentlyPlayed(); const reversedPlays = [...referencePlays]; // actual order they were discovered in (oldest to newest) reversedPlays.reverse(); if(this.transientDiscovered.data.length === 0) { // and add to discovered since its empty for(const refPlay of reversedPlays) { this.transientDiscovered.add(refPlay); } } } return true; }}