From 06ed3f438897f7441d3878f2e94c497b317729ae Mon Sep 17 00:00:00 2001 From: Exerra Date: Tue, 14 Jul 2026 00:24:44 +0300 Subject: [PATCH] feat: add Apple Music source support Implement Apple Music source, including configuration types, environment variable handling, and source initialization logic. Refactored SourceType definitions to improve maintainability. --- .gitignore | 6 +- package.json | 1 + src/backend/common/infrastructure/Atomic.ts | 2 +- .../config/source/applemusic.ts | 27 ++ .../infrastructure/config/source/sources.ts | 11 +- src/backend/sources/AppleMusicSource.ts | 276 ++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 28 ++ src/core/Atomic.ts | 37 +-- 8 files changed, 351 insertions(+), 37 deletions(-) create mode 100644 src/backend/common/infrastructure/config/source/applemusic.ts create mode 100644 src/backend/sources/AppleMusicSource.ts diff --git a/.gitignore b/.gitignore index 874fa825..f7cd5758 100644 --- a/.gitignore +++ b/.gitignore @@ -155,4 +155,8 @@ storybook-static *.db *.db* -**/lexicons/**/*.json \ No newline at end of file +**/lexicons/**/*.json + +bun.lock +.DS_Store +**/.DS_Store \ No newline at end of file diff --git a/package.json b/package.json index dd763c19..6a1d0c1f 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "musicbrainz-api": "^0.27.0", "nanoid": "^3.3.1", "neotraverse": "^0.6.18", + "node-musickit-api": "../node-musickit-api", "node-object-hash": "^3.1.1", "normalize-url": "^8.0.1", "ntfy": "^1.15.2", diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index bfdf9381..5f813c13 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -14,7 +14,7 @@ export interface LeveledLogData extends LogDataPretty { levelLabel: string } -export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; +export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic', 'applemusic']; export interface ComponentIdentifier { type: SourceType | ClientType diff --git a/src/backend/common/infrastructure/config/source/applemusic.ts b/src/backend/common/infrastructure/config/source/applemusic.ts new file mode 100644 index 00000000..9b2aff8b --- /dev/null +++ b/src/backend/common/infrastructure/config/source/applemusic.ts @@ -0,0 +1,27 @@ +import type {PollingOptions} from "../common.ts"; +import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; + +export interface AppleMusicKey { + id: string + teamId: string + p8: string +} + +export interface AppleMusicData extends CommonSourceData, PollingOptions { + key?: AppleMusicKey + token?: string + mediaUserToken?: string + storefront?: string +} + +export interface AppleMusicSourceConfig extends CommonSourceConfig { + data?: AppleMusicData + options?: CommonSourceOptions & { + logAuth?: boolean + logDiff?: boolean + } +} + +export interface AppleMusicSourceAIOConfig extends AppleMusicSourceConfig { + type: 'applemusic' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index e12dfbb1..7957f4fd 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -27,6 +27,7 @@ import type {TealSourceAIOConfig, TealSourceConfig} from "./tealfm.ts"; import type {RockskySourceAIOConfig, RockskySourceConfig} from "./rocksky.ts"; import type {LibrefmSouceAIOConfig, LibrefmSourceConfig} from "./librefm.ts"; import type {SonosSourceAIOConfig, SonosSourceConfig} from "./sonos.ts"; +import type {AppleMusicSourceAIOConfig, AppleMusicSourceConfig} from "./applemusic.ts"; export type SourceConfig = @@ -58,7 +59,8 @@ export type SourceConfig = | KoitoSourceConfig | TealSourceConfig | RockskySourceConfig - | SonosSourceConfig; + | SonosSourceConfig + | AppleMusicSourceConfig; export type SourceAIOConfig = SpotifySourceAIOConfig @@ -89,7 +91,8 @@ export type SourceAIOConfig = | KoitoSourceAIOConfig | TealSourceAIOConfig | RockskySourceAIOConfig - | SonosSourceAIOConfig; + | SonosSourceAIOConfig + | AppleMusicSourceAIOConfig; /** Used for docusaurus schemas * We need to show "array of" for each type of config when looking at File Config @@ -128,6 +131,7 @@ export type KoitoSourceConfigs = KoitoSourceConfig[]; export type TealSourceConfigs = TealSourceConfig[]; export type RockskySourceConfigs = RockskySourceConfig[]; export type SonosSourceConfigs = SonosSourceConfig[]; +export type AppleMusicSourceConfigs = AppleMusicSourceConfig[]; export const atomicSourceInterfaces = [ @@ -159,7 +163,8 @@ export const atomicSourceInterfaces = [ 'KoitoSourceConfig', 'TealSourceConfig', 'RockskySourceConfig', - 'SonosSourceConfig' + 'SonosSourceConfig', + 'AppleMusicSourceConfig' ]; export const sourceInterfaces = [ diff --git a/src/backend/sources/AppleMusicSource.ts b/src/backend/sources/AppleMusicSource.ts new file mode 100644 index 00000000..776ab573 --- /dev/null +++ b/src/backend/sources/AppleMusicSource.ts @@ -0,0 +1,276 @@ +import dayjs, { type Dayjs } from "dayjs"; +import type EventEmitter from "events"; +import { MusicKit, type Song } from "node-musickit-api"; +import type { PlayObject, PlayObjectMinimal } from "../../core/Atomic.ts"; +import type { InternalConfig } from "../common/infrastructure/Atomic.ts"; +import type { AppleMusicSourceConfig } from "../common/infrastructure/config/source/applemusic.ts"; +import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts"; +import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; +import { + getPlaysDiff, + humanReadableDiff, + type PlayOrderChangeType, + type PlayOrderConsistencyResults, + playsAreAddedOnly, + playsAreBumpedOnly, + playsAreSortConsistent +} from "../utils/PlayComparisonUtils.ts"; + +interface AppleMusicTrack { + id: string + name: string + artistName: string + albumName: string + durationInMillis: number + [key: string]: any +} + +export default class AppleMusicSource extends AbstractSource { + + requiresAuth = true; + requiresAuthInteraction = false; + + declare config: AppleMusicSourceConfig + + recentlyPlayed: PlayObject[] = []; + musicKit!: MusicKit; + + recentChangedHistoryResponses: {ts: Dayjs, plays: PlayObject[]}[] = []; + + constructor(name: string, config: AppleMusicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('applemusic', name, config, internal, emitter); + this.canPoll = true; + this.canBacklog = false; + this.supportsUpstreamRecentlyPlayed = true; + } + + protected async doBuildInitData(): Promise { + const { + key, + token, + mediaUserToken, + } = this.config.data || {}; + + if (!key && !token) { + throw new Error('Either "key" or "token" config property is required for Apple Music authentication'); + } + if (!mediaUserToken) { + throw new Error('"mediaUserToken" config property is required for user-specific endpoints like recently played.'); + } + + if (key) { + this.musicKit = new MusicKit({ key, mediaUserToken }); + } else { + this.musicKit = new MusicKit({ + key: { id: '__token__', teamId: '__token__', p8: '__token__' }, + mediaUserToken, + }); + this.musicKit.token = token!; + } + + return true; + } + + doAuthentication = async (): Promise => { + try { + if (this.config.data?.key) { + await this.musicKit.auth(); + } + return true; + } catch (e) { + this.logger.error(new Error('Apple Music authentication failed', {cause: e})); + return false; + } + } + + protected async doCheckConnection(): Promise { + return undefined; + } + + static formatPlayObj(track: AppleMusicTrack, options: {newFromSource?: boolean} = {}): PlayObject { + const {newFromSource = false} = options; + const play: PlayObjectMinimal = { + data: { + artists: artistNamesToCredits([track.artistName]), + album: track.albumName, + track: track.name, + duration: track.durationInMillis ? Math.round(track.durationInMillis / 1000) : undefined, + }, + meta: { + source: 'AppleMusic', + musicService: 'Apple Music', + trackId: track.id, + newFromSource, + } + } + return baseFormatPlayObj(track, play); + } + + recentlyPlayedTrackIsValid = (playObj: PlayObject) => playObj.meta.newFromSource + + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { + return this.getTracks(30); + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + const plays = await this.getTracks(20); + return this.parseRecentAgainstResponse(plays).plays; + } + + protected getBackloggedPlays = async (options: RecentlyPlayedOptions): Promise => { + const plays = await this.getUpstreamRecentlyPlayed(options); + if (plays.length === 0) return plays; + let cumDur = 0; + const now = dayjs(); + return plays.map(play => { + const dated = { + ...play, + data: { ...play.data, playDate: now.subtract(cumDur, 'seconds') }, + }; + cumDur += play.data.duration ?? 1; + return dated; + }); + } + + private getTracks = async (limit: number): Promise => { + const clampedLimit = Math.max(1, Math.min(30, Math.round(limit))); + const result = await this.musicKit.me.history.getRecentlyPlayedTracks({ limit: clampedLimit as 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30, types: ["songs"] }); + if (result.error) { + throw new Error(result.error); + } + if (!result.data) { + return []; + } + return (result.data as unknown as AppleMusicTrack[]).map(track => AppleMusicSource.formatPlayObj(track)); + } + + getIncomingHistoryConsistencyResult = (plays: PlayObject[]): {plays: PlayObject[], consistent: boolean, diffType?: 'bump' | 'added', diffResults?: PlayOrderConsistencyResults, reason?: string} => { + const results: {plays: PlayObject[], consistent: boolean} = { + plays: [], + consistent: true + } + + if(playsAreSortConsistent(this.recentlyPlayed, plays)) { + return {plays: [], consistent: true}; + } + + let diffResults: PlayOrderConsistencyResults; + let diffType: 'bump' | 'added' | undefined; + diffResults = playsAreBumpedOnly(this.recentlyPlayed, plays); + if(diffResults[0] === true) { + diffType = 'bump'; + if(diffResults[2] !== 'prepend') { + return {...results, consistent: false, reason: `Previously seen Apple Music history was bumped in an unexpected way (${diffResults[2]}), resetting history to new list`, diffType, diffResults}; + } + return {...results, plays: [...diffResults[1]!].reverse(), diffType, diffResults}; + } else { + diffResults = playsAreAddedOnly(this.recentlyPlayed, plays); + if(diffResults[0] === true) { + diffType = 'added'; + if(diffResults[2] !== 'prepend') { + return {...results, consistent: false, reason: `New tracks were added to Apple Music history in an unexpected way (${diffResults[2]}), resetting watched history to new list`, diffType, diffResults}; + } + const revertedToRecent = this.recentChangedHistoryResponses.findIndex(x => playsAreSortConsistent(x.plays, plays)); + if(revertedToRecent !== -1) { + return {...results, consistent: false, reason: `Apple Music History has exact order as another recent response (${revertedToRecent + 1} ago) 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.`, diffType, diffResults}; + } + return {...results, plays: [...diffResults[1]!].reverse(), diffType, diffResults}; + } else { + return {...results, consistent: false, reason: 'Apple Music History returned temporally inconsistent order, resetting history to new list.'}; + } + } + } + + parseRecentAgainstResponse = (responsePlays: PlayObject[]): {plays: PlayObject[], consistent: boolean} => { + + let results: {plays: PlayObject[], consistent: boolean} = { + 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 > 0 && this.config.options?.logDiff === true)) { + const playsDiff = getPlaysDiff(this.recentlyPlayed, plays) + const humanDiff = humanReadableDiff(this.recentlyPlayed, plays, playsDiff); + const diffMsg = `Changes from last seen list detected as ${diffType ?? 'unknown'} type:\n${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]; + }, [] as PlayObject[]); + + 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]; + reversedPlays.reverse(); + const currentDiscovered = await this.getRecentlyDiscoveredPlays(false); + if(currentDiscovered === undefined || currentDiscovered.length === 0) { + for(const refPlay of reversedPlays) { + await this.addPlayToDiscovered(refPlay); + } + } + } + return true; + } +} diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 17f7ad4a..2dda83d6 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -32,6 +32,7 @@ import type {WebScrobblerData, WebScrobblerSourceConfig} from "../common/infrast import type {YTMusicData, YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.ts"; import type {YandexMusicBridgeData, YandexMusicBridgeSourceConfig} from "../common/infrastructure/config/source/ymbridge.ts"; import type {SonosData, SonosSourceConfig} from "../common/infrastructure/config/source/sonos.ts"; +import type {AppleMusicSourceConfig} from "../common/infrastructure/config/source/applemusic.ts"; import type { WildcardEmitter } from "../common/WildcardEmitter.ts"; import { nonEmptyObj, parseBool } from "../utils.ts"; import { removeUndefinedKeys } from '../../core/DataUtils.ts'; @@ -170,6 +171,8 @@ export default class ScrobbleSources { return "RockskySourceConfig"; case 'sonos': return 'SonosSourceConfig'; + case 'applemusic': + return 'AppleMusicSourceConfig'; } } @@ -829,6 +832,26 @@ export default class ScrobbleSources { }); } } break; + case 'applemusic': { + const data = removeUndefinedKeys({ + mediaUserToken: process.env.AM_MEDIA_USER_TOKEN, + token: process.env.AM_TOKEN, + storefront: process.env.AM_STOREFRONT, + }, false); + const p = getCommonComponentEnvConfig('AM'); + if (nonEmptyObj(data) || nonEmptyObj(p)) { + configs.push({ + type: 'applemusic', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: data, + ...p, + options: transformPresetEnv('AM') + }); + } + } break; case 'sonos': { const data: SonosData = removeUndefinedKeys({ host: process.env.SONOS_HOST, @@ -1137,6 +1160,11 @@ export default class ScrobbleSources { newSource = await new SonosSource(name, compositeConfig as SonosSourceConfig, this.internalConfig, this.emitter); break; } + case 'applemusic': { + const AppleMusicSource = (await import('./AppleMusicSource.ts')).default; + newSource = await new AppleMusicSource(name, compositeConfig as AppleMusicSourceConfig, this.internalConfig, this.emitter); + break; + } default: break; } diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 9eba6790..c897ba91 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -17,36 +17,7 @@ export const isComponentTypeClient = (type: string): type is ComponentTypeClient export const isComponentType = (type: string): type is ComponentType => isComponentTypeClient(type) || isComponentTypeSource(type); export interface SourceStatusData { status: string; - type: 'spotify' - | 'plex' - | 'tautulli' - | 'subsonic' - | 'jellyfin' - | 'lastfm' - | 'librefm' - | 'deezer' - | 'endpointlz' - | 'endpointlfm' - | 'ytmusic' - | 'ymbridge' - | 'mpris' - | 'mopidy' - | 'musiccast' - | 'listenbrainz' - | 'jriver' - | 'kodi' - | 'webscrobbler' - | 'chromecast' - | 'maloja' - | 'musikcube' - | 'mpd' - | 'vlc' - | 'icecast' - | 'azuracast' - | 'koito' - | 'tealfm' - | 'rocksky' - | 'sonos'; + type: SourceType; display: string; tracksDiscovered: number; name: string; @@ -746,7 +717,8 @@ export type SourceType = 'koito' | 'tealfm' | 'rocksky' | - 'sonos'; + 'sonos' | + 'applemusic'; export const sourceTypes: SourceType[] = [ 'spotify', 'plex', @@ -776,7 +748,8 @@ export const sourceTypes: SourceType[] = [ 'koito', 'tealfm', 'rocksky', - 'sonos' + 'sonos', + 'applemusic' ]; export const isSourceType = (data: string): data is SourceType => { return sourceTypes.includes(data as SourceType); -- 2.51.2