Compare commits

...
Author SHA1 Message Date
FoxxMD 7014f5039e POC apple base source 2026-02-05 15:21:45 +00:00
10 changed files with 381 additions and 19 deletions
+117
View File
@@ -78,6 +78,7 @@
"mpd-api": "^1.1.2",
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
"node-musickit-api": "^2.1.1",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.7.6",
@@ -8359,6 +8360,58 @@
"node": ">=0.2.0"
}
},
"node_modules/jsonwebtoken": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz",
"integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^5.6.0"
},
"engines": {
"node": ">=4",
"npm": ">=1.4.28"
}
},
"node_modules/jsonwebtoken/node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken/node_modules/jws": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/jwa": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
@@ -8727,18 +8780,60 @@
"dev": true,
"peer": true
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -9389,6 +9484,28 @@
}
}
},
"node_modules/node-musickit-api": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/node-musickit-api/-/node-musickit-api-2.1.1.tgz",
"integrity": "sha512-6dl6KbvSSXZyabxvFEYhW3yQbrqM0pn/8b0BNk2wHJfjIxWkgjMl+Uh0q/NkxdWIq/pY1jTsGuVEfraRDv5nQQ==",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
"jsonwebtoken": "^8.5.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/node-musickit-api/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/node-object-hash": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-object-hash/-/node-object-hash-3.1.1.tgz",
+1
View File
@@ -112,6 +112,7 @@
"mpd-api": "^1.1.2",
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
"node-musickit-api": "^2.1.1",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.7.6",
+6 -3
View File
@@ -35,7 +35,8 @@ export type SourceType =
| 'koito'
| 'tealfm'
| 'rocksky'
| 'sonos';
| 'sonos'
| 'apple';
export const sourceTypes: SourceType[] = [
'spotify',
@@ -65,7 +66,8 @@ export const sourceTypes: SourceType[] = [
'koito',
'tealfm',
'rocksky',
'sonos'
'sonos',
'apple'
];
export const sourceInterfaces = [
@@ -97,7 +99,8 @@ export const sourceInterfaces = [
'KoitoSourceConfig',
'TealSourceConfig',
'RockskySourceConfig',
'SonosSourceConfig'
'SonosSourceConfig',
'AppleMusicSourceConfig'
]
export const isSourceType = (data: string): data is SourceType => {
@@ -0,0 +1,48 @@
import {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.js";
import {PollingOptions} from "../common.js";
// See issue for more documentation
// https://github.com/FoxxMD/multi-scrobbler/issues/9#issuecomment-946871774
export interface AppleMusicData extends CommonSourceData, PollingOptions {
/**
* Contents of MusicKit private key or a path to the key file
*
* https://help.apple.com/developer-account/#/devcdfbb56a3
* */
key: string
/**
* Team Id (tid) from Developer account
* */
teamId: string
/**
* Key identifier (kid) from Developer account
* */
keyId: string
/**
* URL to another MS instance running the apple music source *server*
* */
endpoint: string
}
export interface AppleMusicOptions extends CommonSourceOptions {
/**
* Always log history diff
*
* By default MS will log to `WARN` if history diff is inconsistent but does not log if diff is expected (on new tracks found)
* Set this to `true` to ALWAYS log diff on new tracks. Expected diffs will log to `DEBUG` and inconsistent diffs will continue to log to `WARN`
*
* @default false
*/
logDiff?: boolean
}
export interface AppleMusicSourceConfig extends CommonSourceConfig {
data: AppleMusicData
options?: AppleMusicOptions
}
export interface AppleMusicAIOSourceConfig extends AppleMusicSourceConfig {
type: 'apple'
}
@@ -26,6 +26,7 @@ import { TealSourceAIOConfig, TealSourceConfig } from "./tealfm.js";
import { RockskySourceAIOConfig, RockskySourceConfig } from "./rocksky.js";
import { LibrefmSouceAIOConfig, LibrefmSourceConfig } from "./librefm.js";
import { SonosSourceAIOConfig, SonosSourceConfig } from "./sonos.js";
import { AppleMusicAIOSourceConfig, AppleMusicSourceConfig } from "./apple.js";
export type SourceConfig =
@@ -57,7 +58,8 @@ export type SourceConfig =
| KoitoSourceConfig
| TealSourceConfig
| RockskySourceConfig
| SonosSourceConfig;
| SonosSourceConfig
| AppleMusicSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -88,4 +90,5 @@ export type SourceAIOConfig =
| KoitoSourceAIOConfig
| TealSourceAIOConfig
| RockskySourceAIOConfig
| SonosSourceAIOConfig;
| SonosSourceAIOConfig
| AppleMusicAIOSourceConfig;
+11
View File
@@ -17,6 +17,7 @@ import LibrefmScrobbler from "../scrobblers/LibrefmScrobbler.js";
import LibrefmSource from "../sources/LibrefmSource.js";
import e from "express";
import AbstractSource from "../sources/AbstractSource.js";
import { AppleSource } from "../sources/AppleSource.js";
export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
app.use('/api/client/auth', clientMiddle);
@@ -65,6 +66,8 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
req.session.deezerSource = name;
return passport.authenticate(`deezer-${source.name}`)(req,res,next);
case 'apple':
return res.render('apple', {token: source.generateDeveloperToken()});
case 'ytmusic':
await (source as YTMusicSource).reauthenticate();
res.redirect((source as YTMusicSource).verificationUrl);
@@ -137,6 +140,14 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
responseContent = result;
}
return res.send(responseContent);
} else if(req.url.includes('apple')) {
const {
query: {
token
} = {}
} = req;
const entity = scrobbleSources.getByName(state) as AppleSource;
await entity.handleAuthCodeCallback({token});
} 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...
+168
View File
@@ -0,0 +1,168 @@
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
import {AppleMusicSourceConfig} from "../common/infrastructure/config/source/apple.js";
import {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.js";
import EventEmitter from "events";
import {parseBool, readText, writeFile} from "../utils.js";
import MusicKit from 'node-musickit-api/personalized/index.js';
import {createJWT} from 'node-musickit-api/modules/createJWT.js';
import { readJson } from "../utils/DataUtils.js";
import {resolve} from 'path';
import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js";
import dayjs from "dayjs";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js";
export class AppleSource extends AbstractSource {
requiresAuth = true;
requiresAuthInteraction = true;
declare config: AppleMusicSourceConfig;
recentlyPlayed: PlayObject[] = [];
workingCredsPath: string;
keyContents?: string
userToken?: string
apiClient: MusicKit;
constructor(name: any, config: AppleMusicSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('apple', name, config, internal, emitter);
this.canPoll = true;
this.supportsUpstreamRecentlyPlayed = true;
this.workingCredsPath = resolve(this.configDir, `apple-${this.name}.json`);
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};
}
}
}
protected async doBuildInitData(): Promise<true | string | undefined> {
if (this.config.data.key !== undefined || this.config.data.teamId !== undefined || this.config.data.keyId !== undefined) {
if (this.config.data.key === undefined) {
throw new Error(`For apple source as SERVER the property 'key' must be defined`);
} else {
// try to parse as file
try {
this.keyContents = await readText(this.config.data.key, {throwOnNotFound: false})
if (this.keyContents === undefined) {
// could not find as file, that's fine
this.keyContents = this.config.data.key;
}
} catch (e) {
throw new Error(`Apple config 'key' property seems to be a valid file path but could not be read: ${e.message}`);
}
}
if (this.config.data.teamId === undefined) {
throw new Error(`For apple source as SERVER the property 'teamId' must be defined`);
}
if (this.config.data.keyId === undefined) {
throw new Error(`For apple source as SERVER the property 'keyId' must be defined`);
}
}
return true;
}
doAuthentication = async () => {
try {
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
if(credFile !== undefined) {
this.userToken = credFile.userToken;
// temp
this.apiClient = new MusicKit({
key: this.keyContents,
teamId: this.config.data.teamId,
keyId: this.config.data.keyId,
userToken: this.userToken,
});
return true;
} else {
throw new Error('No user token has been written yet, Authenticate from the dashboard to complete setup.');
}
} catch (e) {
throw new Error('Current apple credentials file exists but could not be parsed');
}
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
// https://musickit.js.org/#/personalized/recentlyPlayed?id=get-recently-played-tracks
const songs = this.apiClient.getRecentlyPlayed(options.limit ?? 20, 0, "songs");
// TODO fully implement formatPlayObj
const rawPlays = songs.map(x => formatPlayObj(x));
/**
* TODO implement comparing latest list of plays against previously seen/processed plays
*
* See YTMusicSource@parseRecentAgainstResponse -- this is going to be basically the same process
* because both YTMusic and Apple do not return timestamps of when tracks were played
* so we do a state-machine-esque comparison of tracks we *know* we have already seen
* and then "discover" tracks that appear in the latest list that weren't in the previous list,
* with their timestamp set to an approximation of when polling for the latest list happened
*
* It might be better to refactor the YTMusic parseRecentAgainstResponse etc. functions into indepedent functions that can be re-used by both sources
*/
throw new Error('Not implemented');
}
generateDeveloperToken = () => {
return createJWT({
key: this.keyContents,
teamId: this.config.data.teamId,
keyId: this.config.data.keyId,
});
}
handleAuthCodeCallback = async ({token}) => {
await writeFile(this.workingCredsPath, JSON.stringify({
userToken: token
}));
this.userToken = token;
this.logger.info('Got apple user music token callback!');
// temp
this.apiClient = new MusicKit({
key: this.keyContents,
teamId: this.config.data.teamId,
keyId: this.config.data.keyId,
userToken: this.userToken,
})
return true;
}
}
export const formatPlayObj = (obj: object, options: FormatPlayObjectOptions = {}): PlayObjectLifecycleless => {
// node-musickit-api does not have typings
// need to make an interface from response reference here https://developer.apple.com/documentation/applemusicapi/get-v1-me-recent-played-tracks
const {newFromSource = false} = options;
const play: PlayObjectLifecycleless = {
data: {
// 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: {
}
}
throw new Error('Not implemented yet');
return baseFormatPlayObj(obj, play);
}
+7
View File
@@ -43,6 +43,7 @@ import { RockskySourceConfig } from '../common/infrastructure/config/source/rock
import { CommonSourceOptions } from '../common/infrastructure/config/source/index.js';
import { ExternalMetadataTerm, PlayTransformHooks, PlayTransformOptions } from '../common/infrastructure/Transform.js';
import { LibrefmSourceConfig } from '../common/infrastructure/config/source/librefm.js';
import { AppleMusicSourceConfig } from '../common/infrastructure/config/source/apple.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -159,6 +160,8 @@ export default class ScrobbleSources {
return "RockskySourceConfig";
case 'sonos':
return 'SonosSourceConfig';
case 'apple':
return 'AppleMusicSourceConfig';
}
}
@@ -1019,6 +1022,10 @@ export default class ScrobbleSources {
const {SonosSource} = (await import('./SonosSource.js'));
newSource = await new SonosSource(name, compositeConfig as SonosSourceConfig, this.internalConfig, this.emitter);
break;
case 'apple':
const {AppleSource} = (await import('./AppleSource.js'));
newSource = await new AppleSource(name, compositeConfig as AppleMusicSourceConfig, this.internalConfig, this.emitter);
break;
default:
break;
}
+16 -13
View File
@@ -25,19 +25,22 @@ import {
//const { default: Ajv } = AjvNS;
dayjs.extend(utc);
export async function readText(path: any) {
await promises.access(path, constants.R_OK);
const data = await promises.readFile(path);
return data.toString();
// return new Promise((resolve, reject) => {
// fs.readFile(path, 'utf8', function (err, data) {
// if (err) {
// reject(err);
// }
// resolve(JSON.parse(data));
// });
// });
export async function readText(path: any, {throwOnNotFound = true} = {}) {
try {
await promises.access(path, constants.R_OK);
const data = await promises.readFile(path);
return data.toString();
} catch (e) {
const {code} = e;
if (code === 'ENOENT') {
if (throwOnNotFound) {
throw new Error(`No file found at given path: ${path}`, {cause: e});
} else {
return;
}
}
throw new Error(`Encountered error while parsing file: ${path}`, {cause: e})
}
}
export async function writeFile(path: any, text: any) {
+2 -1
View File
@@ -36,7 +36,8 @@ export interface SourceStatusData {
| 'koito'
| 'tealfm'
| 'rocksky'
| 'sonos';
| 'sonos'
| 'apple';
display: string;
tracksDiscovered: number;
name: string;