feat(mixcloud): support OAuth apps and private/pro histories

Add optional clientId/clientSecret config for a Mixcloud application.
When both are defined the source runs the OAuth authorization flow
(redirecting through /api/mixcloud/callback), persists the received
access token next to the config like the spotify source, and reads
listens from /me/listens/ -- which includes the private and pro
listening history the public endpoint does not expose. The username is
discovered from the authorized user's profile.

With only a username the source behaves exactly as before, so existing
configs keep working unchanged.
This commit is contained in:
Anand Hegde 2026-10-04 12:04:19 +05:30 • committed by FoxxMD
parent e17ad7e0e4
commit 6fa5ea47a0
7 changed files with 405 additions and 18 deletions

View file

@ -5,8 +5,17 @@
"enable": true,
"clients": [],
"data": {
// Option 1: monitor a user's public listening history (no authentication needed)
"username": "MyUsername",
// Option 2: authenticate with a Mixcloud application to monitor your own
// listening history, including private (pro account) history.
// Create an application at https://www.mixcloud.com/developers/ to get
// these, then use the Authorize button on the source to complete sign-in.
// When used, `username` is optional.
// "clientId": "...",
// "clientSecret": "...",
// optional: polling interval in seconds (default 60)
"interval": 60
}

View file

@ -1,6 +1,8 @@
| Environmental Variable | Type | Default | Description |
| ------------------------- | ------- | ---------------------- | ------------------------------------------------------------- |
| _**`MIXCLOUD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `MIXCLOUD_NAME` | string | Value of `MIXCLOUD_ID` | A vanity name EX `My Cool Component` |
| `MIXCLOUD_ENABLE` | boolean | true | Should this component be used? |
| _**`MIXCLOUD_USERNAME`**_ | string | | Mixcloud username whose listening history should be monitored |
| Environmental Variable | Type | Default | Description |
| ------------------------ | ------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| _**`MIXCLOUD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `MIXCLOUD_NAME` | string | Value of `MIXCLOUD_ID` | A vanity name EX `My Cool Component` |
| `MIXCLOUD_ENABLE` | boolean | true | Should this component be used? |
| `MIXCLOUD_USERNAME` | string | | Mixcloud username whose listening history should be monitored. Only required when clientId/clientSecret are not defined. |
| `MIXCLOUD_CLIENT_ID` | string | | Client ID of a Mixcloud application, from https://www.mixcloud.com/developers/. When defined (with clientSecret) the source authenticates with OAuth and can monitor private (pro account) listening histories. |
| `MIXCLOUD_CLIENT_SECRET` | string | | Client secret of a Mixcloud application, from https://www.mixcloud.com/developers/. |

View file

@ -13,7 +13,7 @@ import ENVConfig from "@site/docs/configuration/sources/_env_configs/_mixcloud.m
[**Mixcloud**](https://www.mixcloud.com/) is a platform for DJ mixes, radio shows, and podcasts.
This Source monitors the public [listening history](https://www.mixcloud.com/developers/#listens) of a Mixcloud user and scrobbles newly discovered listens to configured clients.
This Source monitors the [listening history](https://www.mixcloud.com/developers/#listens) of a Mixcloud user and scrobbles newly discovered listens to configured clients.
:::info[Mixes, not Tracks]
@ -27,10 +27,24 @@ Mixcloud's public API does not expose tracklists for mixes so each listen is scr
## Setup
There are two ways to configure this Source:
### Public history with a username
No authentication is required. Your Mixcloud listening history must be **public** (the default), which you can verify by visiting `https://www.mixcloud.com/YourUsername/listens/` while logged out.
Your **username** is the last part of your profile URL IE `https://www.mixcloud.com/MyUsername/` => `MyUsername`
### Your own history with an application (including pro accounts)
If your listening history is private, or you have a pro account, authenticate with a Mixcloud application instead:
1. [Create an application](https://www.mixcloud.com/developers/) to get a **clientId** and **clientSecret**
2. Set them in the source configuration -- `username` is not needed, the authorized user's listens are monitored
3. Start multi-scrobbler and use the **Authorize** button/link for this Source to complete sign-in at Mixcloud
The authorize flow redirects to `/api/mixcloud/callback` on the address the multi-scrobbler web UI is served from (default `http://localhost:9078`) -- if multi-scrobbler is behind a reverse proxy, complete sign-in from that address. The access token is stored on your machine and used for future restarts, sign-in is only needed once.
Mixes are long, so this Source polls less frequently than others by default: every 60 seconds, backing off to at most 300 seconds when there is no activity.
## Configuration

View file

@ -8,14 +8,32 @@ export const mixcloudDataSchema = z.object({
/**
* Mixcloud username whose listening history should be monitored
*
* Only required when clientId/clientSecret are not defined. When authenticating
* with an application the username is discovered from the authorized user's profile.
*
* This is the last part of your profile URL IE https://www.mixcloud.com/MyUsername/ => MyUsername
*
* @examples ["MyUsername"]
* */
username: z.string().meta({
description: "Mixcloud username whose listening history should be monitored",
username: z.string().optional().meta({
description: "Mixcloud username whose listening history should be monitored. Only required when clientId/clientSecret are not defined.",
examples: ["MyUsername"]
}),
/**
* Client ID of a Mixcloud application, from https://www.mixcloud.com/developers/
*
* When defined (with clientSecret) the source authenticates with OAuth and can monitor
* private (pro account) listening histories. The username is then optional.
* */
clientId: z.string().optional().meta({
description: "Client ID of a Mixcloud application, from https://www.mixcloud.com/developers/. When defined (with clientSecret) the source authenticates with OAuth and can monitor private (pro account) listening histories."
}),
/**
* Client secret of a Mixcloud application, from https://www.mixcloud.com/developers/
* */
clientSecret: z.string().optional().meta({
description: "Client secret of a Mixcloud application, from https://www.mixcloud.com/developers/."
}),
});
export type MixcloudData = z.infer<typeof mixcloudDataSchema>;
@ -30,6 +48,8 @@ export type MixcloudSourceConfig = z.infer<typeof mixcloudSourceConfigSchema>;
const envDataSchema = z.object({
MIXCLOUD_USERNAME: mixcloudDataSchema.shape.username,
MIXCLOUD_CLIENT_ID: mixcloudDataSchema.shape.clientId,
MIXCLOUD_CLIENT_SECRET: mixcloudDataSchema.shape.clientSecret,
});
export const envSchemas: EnvSourceSchema<typeof envDataSchema, MixcloudSourceConfig> = {
@ -37,7 +57,9 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, MixcloudSourceCon
prefix: 'MIXCLOUD',
toConfig: (partial) => ({
data: {
username: partial.MIXCLOUD_USERNAME
username: partial.MIXCLOUD_USERNAME,
clientId: partial.MIXCLOUD_CLIENT_ID,
clientSecret: partial.MIXCLOUD_CLIENT_SECRET
}
})
};

View file

@ -3,6 +3,7 @@ import type {Logger} from "@foxxmd/logging";
import type LastfmScrobbler from "../scrobblers/LastfmScrobbler.ts";
import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts";
import type LastfmSource from "../sources/LastfmSource.ts";
import type MixcloudSource from "../sources/MixcloudSource.ts";
import type ScrobbleSources from "../sources/ScrobbleSources.ts";
import type SpotifySource from "../sources/SpotifySource.ts";
import type YTMusicSource from "../sources/YTMusicSource.ts";
@ -32,6 +33,15 @@ export const setupAuthRoutes = (app: Express, router: ReturnType<typeof createTy
res.status(200).send(source.createAuthUrl());
}
} break;
case 'mixcloud': {
const source = req.component as MixcloudSource;
if (source.redirectUri === undefined) {
res.status(400).send('Mixcloud configuration is not valid');
} else {
logger.info('Redirecting to mixcloud authorization url');
res.status(200).send(source.createAuthUrl());
}
} break;
}
});
@ -114,6 +124,38 @@ export const setupAuthRoutes = (app: Express, router: ReturnType<typeof createTy
}
res.send(responseContent);
return;
} else if(req.url.includes('mixcloud')) {
logger.info({label: 'Mixcloud'}, 'Received auth code callback from Mixcloud');
// the redirect uri carries the source's own name (like ytmusic) so match on it as-is
const source = scrobbleSources.getByNameAndType(name as string, 'mixcloud') as MixcloudSource | undefined;
if(source === undefined) {
const e = new Error(`No Mixcloud source with name ${name} was found`);
logger.error(e);
res.status(404).send(e.message);
return;
}
try {
const tokenResult = await source.handleAuthCodeCallback(req.query);
if (tokenResult === true) {
source.clearErrors({predicate: x => findAuthIssue(x) !== undefined});
source.poll().catch((e) => logger.error(e));
} else {
if (tokenResult instanceof Error) {
source.replaceErrors(tokenResult, {predicate: (x) => x.message === tokenResult.message});
source.logger.error(tokenResult);
} else if (typeof tokenResult === 'string') {
const e = new SimpleError(`Token result was unexpected: ${tokenResult}`);
source.replaceErrors(e, {predicate: (x) => x.message === e.message});
source.logger.error(e);
}
}
} catch (e) {
const err = new SimpleError('Unexpected error while trying to authorize code, or save file', { cause: e });
source.replaceErrors(err, {predicate: (x) => err.message === x.message});
source.logger.error(err);
}
res.redirect('/');
return;
} else {
// TODO right now all sources requiring source interaction are covered by logic branches (deezer above and spotify here)
// but eventually should update all source callbacks to url specific URLS to avoid ambiguity...

View file

@ -1,10 +1,15 @@
import dayjs from "dayjs";
import type EventEmitter from "events";
import request from 'superagent';
import { PARSED_FROM, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, PARSED_FROM, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts";
import type { ComponentAuthType } from "../../core/Atomic.ts";
import { artistNamesToCredits } from "../../core/StringUtils.ts";
import { AuthError, SimpleError } from "../common/errors/MSErrors.ts";
import type { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.ts";
import type { MixcloudListen, MixcloudSourceConfig } from "../common/infrastructure/config/source/mixcloud.ts";
import { writeFile } from "../utils/FSUtils.ts";
import { readJson } from "../utils/DataUtils.ts";
import { joinedUrl } from "../utils/NetworkUtils.ts";
import { sortByOldestPlayDate } from "../utils.ts";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts";
@ -12,9 +17,27 @@ import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts"
export default class MixcloudSource extends AbstractSource {
baseUrl = 'https://api.mixcloud.com';
oauthBaseUrl = 'https://www.mixcloud.com/oauth';
declare config: MixcloudSourceConfig;
override authType: ComponentAuthType;
override requiresAuth: boolean = false;
override requiresAuthInteraction: boolean = false;
/**
* Access token received from the OAuth authorization flow, when the source is
* configured with app credentials instead of a username
* */
accessToken?: string;
/**
* Username of the OAuth authorized user, resolved from /me/ — used for API
* paths in place of a configured username
* */
authUsername?: string;
redirectUri?: string;
workingCredsPath?: string;
constructor(name: any, config: MixcloudSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data: {
@ -29,6 +52,22 @@ export default class MixcloudSource extends AbstractSource {
this.supportsUpstreamRecentlyPlayed = true;
// https://www.mixcloud.com/developers/#pagination
this.SCROBBLE_BACKLOG_COUNT = 50;
// app credentials mean an OAuth flow against the authorized user's own
// listens (/me/), which works for both public and pro accounts --
// otherwise the public history of a configured username is monitored and no auth is needed
const {clientId, clientSecret} = this.config.data ?? {};
if (clientId !== undefined && clientSecret !== undefined) {
this.authType = COMPONENT_AUTH_TYPE.interactive;
this.requiresAuth = true;
this.requiresAuthInteraction = true;
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
const u = joinedUrl(this.localUrl, 'api/mixcloud/callback');
u.searchParams.append('name', this.name);
this.redirectUri = u.toString();
} else {
this.authType = COMPONENT_AUTH_TYPE.none;
}
}
static formatPlayObj(obj: MixcloudListen, options: FormatPlayObjectOptions = {}): PlayObject {
@ -72,24 +111,116 @@ export default class MixcloudSource extends AbstractSource {
}
protected async doBuildInitData(): Promise<true | string | undefined> {
if (this.config.data.username === undefined || this.config.data.username.trim() === '') {
throw new Error('username must be defined');
const {username, clientId, clientSecret} = this.config.data ?? {};
if (clientId !== undefined || clientSecret !== undefined) {
if (clientId === undefined || clientSecret === undefined) {
throw new Error('clientId and clientSecret must both be defined');
}
} else if (username === undefined || username.trim() === '') {
throw new Error('username must be defined when clientId/clientSecret are not');
}
if (this.workingCredsPath !== undefined) {
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false, interpolateEnvs: false}) as any;
this.accessToken = creds?.token;
if (this.accessToken === undefined) {
this.logger.info('No access token is present. User interaction for authentication is required.');
}
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
}
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
// also verifies the configured user exists
await this.callApi(`/${encodeURIComponent(this.config.data.username)}/`);
if (this.accessToken !== undefined) {
// also verifies the access token is accepted
await this.callApi('/me/');
} else {
// also verifies the configured user exists (validated at init)
await this.callApi(`/${encodeURIComponent(this.config.data.username!)}/`);
}
return true;
}
doAuthentication = async () => {
try {
if (this.accessToken === undefined) {
throw new AuthError('Cannot use API until an access token has been received from the authorization flow.', {unrecoverable: false});
}
await this.resolveAuthUser();
return true;
} catch (e: any) {
if(e instanceof AuthError) {
throw e;
}
// callApi wraps superagent errors, where the status code lives
const status: number | undefined = e?.cause?.status ?? e?.status;
throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: status !== undefined && [401, 403].includes(status)});
}
}
createAuthUrl = () => {
const u = new URL(`${this.oauthBaseUrl}/authorize`);
u.searchParams.set('client_id', this.config.data.clientId!);
u.searchParams.set('redirect_uri', this.redirectUri!);
return u.toString();
}
handleAuthCodeCallback = async ({
error,
code
}: any) => {
try {
if (error !== undefined) {
this.logger.warn('Callback contained an error! User may have denied access?')
this.logger.error(error);
return error;
}
// https://www.mixcloud.com/developers/#authorization
const resp = await request.get(`${this.oauthBaseUrl}/access_token`)
.query({
client_id: this.config.data.clientId,
client_secret: this.config.data.clientSecret,
redirect_uri: this.redirectUri,
code
});
const token = resp.body?.access_token;
if (typeof token !== 'string' || token === '') {
throw new SimpleError('Access token response from Mixcloud did not include a token', {cause: new Error(`Unexpected response body: ${JSON.stringify(resp.body)}`)});
}
this.accessToken = token;
await writeFile(this.workingCredsPath!, JSON.stringify({
token
}));
this.logger.info('Got token from code grant authorization!');
await this.resolveAuthUser();
return true;
} catch (e) {
throw e;
}
}
/**
* Verify the current access token and remember the username it belongs to
* */
protected resolveAuthUser = async () => {
const me = await this.callApi('/me/');
const {username} = me ?? {};
if (typeof username !== 'string' || username === '') {
throw new AuthError('Authorized user response from Mixcloud did not include a username', {unrecoverable: false});
}
this.authUsername = username;
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
const {limit = 20} = options;
this.setStatus('Checking for new Plays');
const body = await this.callApi(`/${encodeURIComponent(this.config.data.username)}/listens/`, {limit});
// /me/listens/ includes private (pro account) history while a username path only exposes public history
// https://www.mixcloud.com/developers/#the-me-shortcut
const path = this.accessToken !== undefined ? '/me/listens/' : `/${encodeURIComponent(this.config.data.username!)}/listens/`;
const body = await this.callApi(path, {limit});
const listens: MixcloudListen[] = body.data ?? [];
return listens
.filter(x => x.listen_time !== undefined)
@ -105,6 +236,9 @@ export default class MixcloudSource extends AbstractSource {
callApi = async (path: string, query: Record<string, any> = {}): Promise<any> => {
try {
if (this.accessToken !== undefined) {
query = {access_token: this.accessToken, ...query};
}
// Mixcloud serves JSON as text/javascript so superagent will not parse it by default
const resp = await request.get(`${this.baseUrl}${path}`)
.query(query)

View file

@ -1,9 +1,12 @@
import { loggerTest } from "@foxxmd/logging";
import { assert, expect } from 'chai';
import EventEmitter from "events";
import { mkdtempSync, readFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, it } from 'mocha';
import { http, HttpResponse } from "msw";
import { PARSED_FROM } from "../../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, PARSED_FROM } from "../../../core/Atomic.ts";
import { artistCreditsToNames } from "../../../core/StringUtils.ts";
import type { MixcloudData, MixcloudListen } from "../../common/infrastructure/config/source/mixcloud.ts";
import MixcloudSource from "../../sources/MixcloudSource.ts";
@ -18,12 +21,25 @@ const mixcloudJson = (body: object, init: ResponseInit = {}) => new HttpResponse
const listens = listensResponse.data as MixcloudListen[];
const createSource = (data: MixcloudData = { username: 'TestUser' }): MixcloudSource => new MixcloudSource('Test', {
const appData: MixcloudData = { clientId: 'cid', clientSecret: 'secret' };
const createSource = (data: MixcloudData = { username: 'TestUser' }, name: string = 'Test'): MixcloudSource => new MixcloudSource(name, {
id: `test-${Date.now()}`,
data,
options: {}
}, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter());
/** A source with app credentials whose credentials file lives in a temp dir */
const createAuthSource = (): { source: MixcloudSource, cleanup: () => void } => {
const configDir = mkdtempSync(join(tmpdir(), 'ms-mixcloud-test-'));
const source = new MixcloudSource('Test', {
id: `test-${Date.now()}`,
data: appData,
options: {}
}, { localUrl: new URL('http://test'), configDir, logger: loggerTest, version: 'test' }, new EventEmitter());
return { source, cleanup: () => rmSync(configDir, { recursive: true, force: true }) };
};
describe('Mixcloud Source', function () {
describe('Play Parsing', function () {
@ -64,6 +80,35 @@ describe('Mixcloud Source', function () {
}
});
it('Does not require auth with only a username', async function () {
const source = createSource();
await source.buildInitData();
expect(source.authType).to.equal(COMPONENT_AUTH_TYPE.none);
expect(source.requiresAuth).to.be.false;
});
it('Fails init when neither username nor app credentials are defined', async function () {
const source = createSource({});
try {
await source.buildInitData();
assert.fail('Expected init to throw');
} catch (e) {
expect(source.buildOK).to.be.false;
expect(((e as Error).cause as Error).message).to.include('username must be defined');
}
});
it('Fails init when clientId is defined without clientSecret', async function () {
const source = createSource({ clientId: 'cid' });
try {
await source.buildInitData();
assert.fail('Expected init to throw');
} catch (e) {
expect(source.buildOK).to.be.false;
expect(((e as Error).cause as Error).message).to.include('clientId and clientSecret must both be defined');
}
});
it('Connection check succeeds when user exists', withRequestInterception(
[
http.get('https://api.mixcloud.com/TestUser/', () => mixcloudJson({
@ -104,6 +149,98 @@ describe('Mixcloud Source', function () {
));
});
describe('Authentication', function () {
it('Requires interactive auth when app credentials are defined', function () {
const { source, cleanup } = createAuthSource();
try {
expect(source.authType).to.equal(COMPONENT_AUTH_TYPE.interactive);
expect(source.requiresAuth).to.be.true;
expect(source.requiresAuthInteraction).to.be.true;
} finally {
cleanup();
}
});
it('Builds an authorize url with client id and redirect uri', function () {
const { source, cleanup } = createAuthSource();
try {
const url = new URL(source.createAuthUrl()!);
expect(url.origin + url.pathname).to.equal('https://www.mixcloud.com/oauth/authorize');
expect(url.searchParams.get('client_id')).to.equal('cid');
expect(url.searchParams.get('redirect_uri')).to.equal('http://test/api/mixcloud/callback?name=Test');
} finally {
cleanup();
}
});
it('Authentication fails when no access token has been received', async function () {
const { source, cleanup } = createAuthSource();
try {
await source.buildInitData();
try {
await source.testAuth(true);
assert.fail('Expected auth to throw');
} catch (e) {
expect(((e as Error).cause as Error).message).to.include('access token');
}
} finally {
cleanup();
}
});
it('Exchanges an oauth code for a token, persists it, and resolves the user', withRequestInterception(
[
http.get('https://www.mixcloud.com/oauth/access_token', ({ request }) => {
const url = new URL(request.url);
const expected = {
client_id: 'cid',
client_secret: 'secret',
redirect_uri: 'http://test/api/mixcloud/callback?name=Test',
code: 'thecode'
};
for (const [key, value] of Object.entries(expected)) {
if (url.searchParams.get(key) !== value) {
return HttpResponse.json({ error: `unexpected ${key}` }, { status: 400 });
}
}
return HttpResponse.json({ access_token: 'tok123' });
}),
http.get('https://api.mixcloud.com/me/', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('access_token') !== 'tok123') {
return mixcloudJson({ error: { type: 'Test', message: 'unexpected access token' } }, { status: 400 });
}
return mixcloudJson({ username: 'ProUser', name: 'Pro User' });
})
],
async function () {
const { source, cleanup } = createAuthSource();
try {
await source.buildInitData();
const result = await source.handleAuthCodeCallback({ code: 'thecode' });
expect(result).to.be.true;
expect(source.accessToken).to.equal('tok123');
expect(source.authUsername).to.equal('ProUser');
const persisted = JSON.parse(readFileSync(source.workingCredsPath!, 'utf8'));
expect(persisted).to.deep.equal({ token: 'tok123' });
} finally {
cleanup();
}
}
));
it('Returns the oauth error when the user denies access', async function () {
const { source, cleanup } = createAuthSource();
try {
const result = await source.handleAuthCodeCallback({ error: 'access_denied' });
expect(result).to.equal('access_denied');
} finally {
cleanup();
}
});
});
describe('Recently Played', function () {
it('Returns listens as history plays sorted oldest first', withRequestInterception(
@ -141,5 +278,32 @@ describe('Mixcloud Source', function () {
expect(plays).to.have.length(0);
}
));
it('Reads listens from the authorized user with an access token', withRequestInterception(
[
http.get('https://api.mixcloud.com/me/listens/', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('access_token') !== 'tok123') {
return mixcloudJson({ error: { type: 'Test', message: 'unexpected access token' } }, { status: 400 });
}
if (url.searchParams.get('limit') !== '20') {
return mixcloudJson({ error: { type: 'Test', message: 'unexpected limit' } }, { status: 400 });
}
return mixcloudJson(listensResponse);
})
],
async function () {
const { source, cleanup } = createAuthSource();
try {
await source.buildInitData();
source.accessToken = 'tok123';
const plays = await source.getRecentlyPlayed();
expect(plays).to.have.length(2);
assert.equal(plays[0].data.track, 'Deep House Mix 2026');
} finally {
cleanup();
}
}
));
});
});