harmonyy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ import crypto from 'node:crypto';
2
+ export * from './types.js';
3
+ const postJSON = async (url, body, headers = {}) => {
4
+ const res = await fetch(url, {
5
+ method: 'POST',
6
+ headers: {
7
+ 'Accept': 'application/json',
8
+ 'Content-Type': 'application/json',
9
+ ...headers
10
+ },
11
+ body: JSON.stringify(body)
12
+ });
13
+ return res.json();
14
+ };
15
+ const throwPatched = (code) => {
16
+ console.error('spotify patched harmony yet again. here\'s how to fix:');
17
+ console.error('1. ensure you have the latest version of harmony installed');
18
+ console.error('2. open an issue @ https://github.com/VillainsRule/harmony/issues/new');
19
+ console.error(`3. make sure to specify "error code ${code}" in the issue`);
20
+ throw new Error(`[harmony] spotify was patched. open an issue on Github (error code ${code}).`);
21
+ };
22
+ export default class Spotify {
23
+ $latestSecret = { version: 1, secret: [] };
24
+ $deviceId = '';
25
+ $cookie = '';
26
+ $accessToken = { clientId: '', accessToken: '', accessTokenExpirationTimestampMs: 0 };
27
+ $clientToken = { token: '', refreshAt: 0 };
28
+ $variables;
29
+ $secretsReady;
30
+ constructor() {
31
+ this.$secretsReady = this.$fetchSecrets();
32
+ this.$secretsReady.catch(() => { });
33
+ setInterval(() => this.$fetchSecrets().catch(() => { }), 1000 * 60 * 60 * 1).unref();
34
+ }
35
+ async $fetchSecrets() {
36
+ const req = await fetch('https://raw.githubusercontent.com/VillainsRule/harmony/master/secrets/secretBytes.json');
37
+ const bytes = await req.json();
38
+ this.$latestSecret = bytes[bytes.length - 1];
39
+ if (!this.$latestSecret)
40
+ throwPatched(3);
41
+ }
42
+ async $getVariables() {
43
+ const mainReq = await fetch('https://open.spotify.com', {
44
+ headers: {
45
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
46
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
47
+ }
48
+ });
49
+ const mainPage = await mainReq.text();
50
+ const deviceId = mainReq.headers.getSetCookie().find(h => h.startsWith('sp_t='))?.split(';')[0].split('=')[1];
51
+ if (!deviceId)
52
+ return throwPatched(4);
53
+ this.$deviceId = deviceId;
54
+ const mainScript = mainPage.match(/<\/script><script src="(.*?)"/)?.[1];
55
+ if (!mainScript)
56
+ return throwPatched(5);
57
+ const scriptContent = await (await fetch(mainScript)).text();
58
+ this.$variables = {
59
+ buildVer: 'unknown',
60
+ buildDate: 'unknown',
61
+ clientVersion: scriptContent.match(/clientVersion:"(.*?)"/)?.[1] || '',
62
+ serverTime: mainReq.headers.get('x-timer')?.match(/S([0-9]+)\./)?.[1] || ''
63
+ };
64
+ return this.$variables;
65
+ }
66
+ $toSecret(input) {
67
+ const inputBytes = [...Buffer.from(input)];
68
+ const transformed = inputBytes.map((e, t) => e ^ ((t % 33) + 9));
69
+ const joined = transformed.map(num => num.toString()).join('');
70
+ const hex_str = Buffer.from(joined).toString('hex');
71
+ return Buffer.from(hex_str, 'hex');
72
+ }
73
+ $generateTOTP(timestamp = Date.now()) {
74
+ const totpSecret = this.$latestSecret ? this.$toSecret(this.$latestSecret.secret) : null;
75
+ if (!totpSecret)
76
+ return throwPatched(2);
77
+ const secretBuffer = Buffer.from(totpSecret);
78
+ const digits = 6;
79
+ const timeStep = 30;
80
+ const time = Math.floor(timestamp / 1000 / timeStep);
81
+ const counter = Buffer.alloc(8);
82
+ counter.writeBigUInt64BE(BigInt(time));
83
+ const hmac = crypto.createHmac('sha1', secretBuffer).update(counter).digest();
84
+ const offset = hmac[hmac.length - 1] & 0xf;
85
+ const code = (((hmac[offset] & 0x7f) << 24) |
86
+ ((hmac[offset + 1] & 0xff) << 16) |
87
+ ((hmac[offset + 2] & 0xff) << 8) |
88
+ (hmac[offset + 3] & 0xff)) % 10 ** digits;
89
+ return code.toString().padStart(digits, '0');
90
+ }
91
+ async $pullAccessToken() {
92
+ await this.$secretsReady;
93
+ if (!this.$variables)
94
+ await this.$getVariables();
95
+ const urlBase = new URL('https://open.spotify.com/api/token');
96
+ const params = new URLSearchParams();
97
+ const totp = this.$generateTOTP();
98
+ params.append('reason', 'init');
99
+ params.append('productType', 'web-player');
100
+ params.append('totp', totp);
101
+ params.append('totpServer', totp);
102
+ params.append('totpVer', this.$latestSecret.version.toString());
103
+ urlBase.search = params.toString();
104
+ const req = await fetch(urlBase, {
105
+ headers: {
106
+ 'Accept': '*/*',
107
+ 'Accept-Language': 'en-US,en;q=0.9',
108
+ 'Accept-Encoding': 'gzip, deflate, br',
109
+ 'Connection': 'keep-alive'
110
+ }
111
+ });
112
+ const res = await req.json();
113
+ if (res.error)
114
+ return throwPatched(1);
115
+ this.$accessToken = res;
116
+ }
117
+ async $getHeaders() {
118
+ if (!this.$accessToken.accessToken)
119
+ await this.$pullAccessToken();
120
+ if (this.$accessToken.accessTokenExpirationTimestampMs - Date.now() <= 1)
121
+ await this.$pullAccessToken();
122
+ return {
123
+ 'Accept': 'application/json',
124
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
125
+ 'Accept-Language': 'en',
126
+ 'App-Platform': 'WebPlayer',
127
+ 'Authorization': `Bearer ${this.$accessToken.accessToken}`,
128
+ 'Cache-Control': 'no-cache',
129
+ 'Content-Type': 'application/json;charset=UTF-8',
130
+ 'Origin': 'https://open.spotify.com',
131
+ 'Referer': 'https://open.spotify.com/',
132
+ 'Spotify-App-Version': this.$variables.clientVersion,
133
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
134
+ };
135
+ }
136
+ async search(query, opts = {}) {
137
+ const data = await postJSON('https://api-partner.spotify.com/pathfinder/v2/query', {
138
+ operationName: 'searchDesktop',
139
+ variables: {
140
+ ...opts,
141
+ searchTerm: query,
142
+ offset: opts.offset ?? 0,
143
+ limit: opts.limit ?? 10,
144
+ numberOfTopResults: opts.numberOfTopResults ?? 5,
145
+ includeAudiobooks: opts.includeAudiobooks ?? false,
146
+ includeArtistHasConcertsField: opts.includeArtistHasConcertsField ?? true,
147
+ includePreReleases: opts.includePreReleases ?? true,
148
+ includeLocalConcertsField: opts.includeLocalConcertsField ?? false,
149
+ includeAuthors: opts.includeAuthors ?? true
150
+ },
151
+ extensions: {
152
+ persistedQuery: {
153
+ version: 1,
154
+ sha256Hash: 'd9f785900f0710b31c07818d617f4f7600c1e21217e80f5b043d1e78d74e6026'
155
+ }
156
+ }
157
+ }, await this.$getHeaders());
158
+ return data.data.searchV2;
159
+ }
160
+ async getPopular(timezone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
161
+ const data = await postJSON('https://api-partner.spotify.com/pathfinder/v2/query', {
162
+ operationName: 'home',
163
+ variables: {
164
+ timeZone: timezone,
165
+ sp_t: this.$deviceId,
166
+ facet: '',
167
+ sectionItemsLimit: 10
168
+ },
169
+ extensions: {
170
+ persistedQuery: {
171
+ version: 1,
172
+ sha256Hash: '72325e84c876c72564fb9ab012f602be8ef6a1fdd3039be2f8b4f2be4c229a30'
173
+ }
174
+ }
175
+ }, await this.$getHeaders());
176
+ return data.data.home.sectionContainer.sections.items;
177
+ }
178
+ async getAlbum(uri) {
179
+ const data = await postJSON('https://api-partner.spotify.com/pathfinder/v2/query', {
180
+ operationName: 'getAlbum',
181
+ variables: {
182
+ uri,
183
+ locale: '',
184
+ offset: 0,
185
+ limit: 50
186
+ },
187
+ extensions: {
188
+ persistedQuery: {
189
+ version: 1,
190
+ sha256Hash: '97dd13a1f28c80d66115a13697a7ffd94fe3bebdb94da42159456e1d82bfee76'
191
+ }
192
+ }
193
+ }, await this.$getHeaders());
194
+ return data.data.albumUnion;
195
+ }
196
+ async getArtist(uri) {
197
+ const data = await postJSON('https://api-partner.spotify.com/pathfinder/v2/query', {
198
+ operationName: 'queryArtistOverview',
199
+ variables: {
200
+ uri,
201
+ locale: ''
202
+ },
203
+ extensions: {
204
+ persistedQuery: {
205
+ version: 1,
206
+ sha256Hash: '1ac33ddab5d39a3a9c27802774e6d78b9405cc188c6f75aed007df2a32737c72'
207
+ }
208
+ }
209
+ }, await this.$getHeaders());
210
+ return data.data.artistUnion;
211
+ }
212
+ }
@@ -0,0 +1,406 @@
1
+ export type OperationName = 'searchArtists' | 'searchAlbums' | 'searchTracks' | 'queryArtistOverview' | 'queryArtistDiscographyAll' | 'getAlbum' | 'fetchPlaylist' | 'fetchPlaylistContents';
2
+ export type CoverArtSource = {
3
+ height: number | null;
4
+ url: string;
5
+ width: number | null;
6
+ };
7
+ export type ExtractedColorItem = {
8
+ hex: string;
9
+ isFallback: boolean;
10
+ };
11
+ export type ExtractedColors = {
12
+ colorDark: ExtractedColorItem;
13
+ };
14
+ export type AlbumCoverArt = {
15
+ extractedColors: ExtractedColors;
16
+ sources: CoverArtSource[];
17
+ };
18
+ export type AvatarImage = {
19
+ extractedColors: ExtractedColors;
20
+ sources: CoverArtSource[];
21
+ };
22
+ export type GalleryImageItem = {
23
+ sources: CoverArtSource[];
24
+ };
25
+ export type Gallery = {
26
+ items: GalleryImageItem[];
27
+ };
28
+ export type ArtistVisuals = {
29
+ avatarImage: AvatarImage | null;
30
+ gallery?: Gallery;
31
+ };
32
+ export type Duration = {
33
+ totalMilliseconds: number;
34
+ };
35
+ export type ContentRating = {
36
+ label: 'NONE' | 'EXPLICIT';
37
+ };
38
+ export type Playability = {
39
+ playable: boolean;
40
+ reason: 'PLAYABLE';
41
+ };
42
+ export type SharingInfo = {
43
+ shareId: string;
44
+ shareUrl: string;
45
+ };
46
+ export type CopyrightItem = {
47
+ text: string;
48
+ type: 'C' | 'P';
49
+ };
50
+ export type Copyright = {
51
+ items: CopyrightItem[];
52
+ };
53
+ export type FullDate = {
54
+ day: number;
55
+ month: number;
56
+ precision: 'DAY' | 'MONTH' | 'YEAR';
57
+ year: number;
58
+ isoString?: string;
59
+ };
60
+ export type AlbumDate = {
61
+ year: number;
62
+ };
63
+ export type TracksSummary = {
64
+ totalCount: number;
65
+ };
66
+ export type ReleaseType = 'ALBUM' | 'SINGLE' | 'EP' | 'COMPILATION';
67
+ export type PaginatedResponse<Item> = {
68
+ items: Item[];
69
+ totalCount: number;
70
+ };
71
+ export type ArtistSimplified = {
72
+ profile: {
73
+ name: string;
74
+ };
75
+ uri: string;
76
+ visuals?: ArtistVisuals;
77
+ };
78
+ export type AlbumOfTrack = {
79
+ coverArt: AlbumCoverArt;
80
+ id: string;
81
+ name: string;
82
+ uri: string;
83
+ };
84
+ export type ArtistStats = {
85
+ followers: number;
86
+ monthlyListeners: number;
87
+ worldRank: number;
88
+ };
89
+ export type Album = {
90
+ __typename: 'Album';
91
+ artists: {
92
+ items: ArtistSimplified[];
93
+ };
94
+ coverArt: AlbumCoverArt;
95
+ date: AlbumDate;
96
+ name: string;
97
+ playability: Playability;
98
+ type: ReleaseType;
99
+ uri: string;
100
+ };
101
+ export type Track = {
102
+ __typename: 'Track';
103
+ albumOfTrack: AlbumOfTrack;
104
+ artists: {
105
+ items: ArtistSimplified[];
106
+ };
107
+ contentRating: ContentRating;
108
+ duration: Duration;
109
+ discNumber: number;
110
+ id: string;
111
+ name: string;
112
+ playability: Playability;
113
+ playcount: string;
114
+ trackNumber: number;
115
+ uri: string;
116
+ };
117
+ export type NotFound = {
118
+ __typename: 'NotFound';
119
+ };
120
+ export type ArtistTopTrack = {
121
+ track: {
122
+ albumOfTrack: AlbumOfTrack;
123
+ artists: {
124
+ items: ArtistSimplified[];
125
+ };
126
+ contentRating: ContentRating;
127
+ discNumber: number;
128
+ duration: Duration;
129
+ id: string;
130
+ name: string;
131
+ playability: Playability;
132
+ playcount: string;
133
+ uri: string;
134
+ };
135
+ };
136
+ export type TopTracksSection = {
137
+ items: ArtistTopTrack[];
138
+ };
139
+ export type ReleaseItem = {
140
+ copyright: Copyright;
141
+ coverArt: AlbumCoverArt;
142
+ date: FullDate;
143
+ id: string;
144
+ label: string;
145
+ name: string;
146
+ playability: Playability;
147
+ sharingInfo: SharingInfo;
148
+ tracks: TracksSummary;
149
+ type: ReleaseType;
150
+ uri: string;
151
+ };
152
+ export type ReleaseSection = PaginatedResponse<ReleaseItem>;
153
+ export type Discography = {
154
+ all?: PaginatedResponse<{
155
+ releases: PaginatedResponse<ReleaseItem>;
156
+ }>;
157
+ albums: ReleaseSection;
158
+ compilations: ReleaseSection;
159
+ latest: ReleaseItem;
160
+ popularReleasesAlbums: ReleaseSection;
161
+ singles: ReleaseSection;
162
+ topTracks: TopTracksSection;
163
+ };
164
+ export type RelatedArtistsSection = PaginatedResponse<Artist>;
165
+ export type RelatedContent = {
166
+ relatedArtists: RelatedArtistsSection;
167
+ };
168
+ export type Artist = {
169
+ __typename: 'Artist';
170
+ id: string;
171
+ uri: string;
172
+ profile: {
173
+ name: string;
174
+ verified?: boolean;
175
+ biography?: {
176
+ type: string;
177
+ text: string;
178
+ };
179
+ };
180
+ headerImage: {
181
+ data: {
182
+ sources: CoverArtSource[];
183
+ };
184
+ } | null;
185
+ visuals?: ArtistVisuals;
186
+ discography: Discography;
187
+ relatedContent: RelatedContent;
188
+ sharingInfo: SharingInfo;
189
+ stats: ArtistStats;
190
+ };
191
+ export type AlbumResponseWrapper = {
192
+ __typename: 'AlbumResponseWrapper';
193
+ data: Album;
194
+ };
195
+ export type ArtistResponseWrapper = {
196
+ __typename: 'ArtistResponseWrapper';
197
+ data: Artist;
198
+ };
199
+ export type TrackResponseWrapper = {
200
+ __typename: 'TrackResponseWrapper';
201
+ data: Track | NotFound;
202
+ };
203
+ export type SearchResultItemWrapper<Result> = {
204
+ item: Result;
205
+ };
206
+ export type AlbumOrPrereleasePage = {
207
+ __typename: 'AlbumOrPrereleasePage';
208
+ items: AlbumResponseWrapper[];
209
+ totalCount: number;
210
+ };
211
+ export type SearchV2 = {
212
+ albumsV2: AlbumOrPrereleasePage;
213
+ artists: PaginatedResponse<ArtistResponseWrapper>;
214
+ tracksV2: PaginatedResponse<SearchResultItemWrapper<TrackResponseWrapper>>;
215
+ };
216
+ export type AlbumUnion = {
217
+ __typename: 'AlbumUnion';
218
+ copyright: Copyright;
219
+ date: FullDate;
220
+ label: string;
221
+ name: string;
222
+ playability: Playability;
223
+ type: ReleaseType;
224
+ artists: PaginatedResponse<ArtistSimplified>;
225
+ coverArt: AlbumCoverArt;
226
+ discs: PaginatedResponse<{
227
+ number: number;
228
+ tracks: {
229
+ totalCount: number;
230
+ };
231
+ }>;
232
+ tracksV2: PaginatedResponse<{
233
+ track: Track;
234
+ }>;
235
+ uri: string;
236
+ };
237
+ export type PlaylistImageItem = {
238
+ extractedColors?: ExtractedColors;
239
+ sources: CoverArtSource[];
240
+ };
241
+ export type PlaylistOwner = {
242
+ __typename: string;
243
+ uri: string;
244
+ username: string;
245
+ name: string;
246
+ avatar?: {
247
+ sources: CoverArtSource[];
248
+ };
249
+ };
250
+ export type PlaylistTrackData = {
251
+ __typename: 'Track' | string;
252
+ uri: string;
253
+ name: string;
254
+ trackDuration: Duration;
255
+ playcount?: string;
256
+ albumOfTrack: {
257
+ uri: string;
258
+ name: string;
259
+ artists: {
260
+ items: ArtistSimplified[];
261
+ };
262
+ coverArt: AlbumCoverArt;
263
+ };
264
+ artists: {
265
+ items: ArtistSimplified[];
266
+ };
267
+ discNumber: number;
268
+ trackNumber: number;
269
+ playability: Playability;
270
+ contentRating: ContentRating;
271
+ };
272
+ export type PlaylistContentItem = {
273
+ uid: string;
274
+ addedAt: {
275
+ isoString: string;
276
+ };
277
+ addedBy?: {
278
+ data: PlaylistOwner;
279
+ };
280
+ item: {
281
+ __typename: string;
282
+ data: PlaylistTrackData;
283
+ };
284
+ };
285
+ export type PlaylistContent = {
286
+ __typename: string;
287
+ totalCount: number;
288
+ pagingInfo: {
289
+ offset: number;
290
+ limit: number;
291
+ };
292
+ items: PlaylistContentItem[];
293
+ };
294
+ export type PlaylistV2 = {
295
+ __typename: string;
296
+ uri: string;
297
+ name: string;
298
+ description: string;
299
+ ownerV2: {
300
+ data: PlaylistOwner;
301
+ };
302
+ images: {
303
+ items: PlaylistImageItem[];
304
+ };
305
+ collaborative: boolean;
306
+ followers: number;
307
+ sharingInfo: SharingInfo;
308
+ content: PlaylistContent;
309
+ };
310
+ export type PathfinderPlaylistResponse = {
311
+ data: {
312
+ playlistV2: PlaylistV2;
313
+ };
314
+ extensions: Record<string, unknown>;
315
+ };
316
+ export type PathfinderSearchResponse = {
317
+ data: {
318
+ searchV2: SearchV2;
319
+ };
320
+ extensions: Record<string, unknown>;
321
+ };
322
+ export type PathfinderArtistOverviewResponse = {
323
+ data: {
324
+ artistUnion: Artist;
325
+ };
326
+ extensions: Record<string, unknown>;
327
+ };
328
+ export type PathfinderGetAlbumResponse = {
329
+ data: {
330
+ albumUnion: AlbumUnion;
331
+ };
332
+ extensions: Record<string, unknown>;
333
+ };
334
+ export type HomeSubtitle = {
335
+ transformedLabel: string;
336
+ translatedBaseText: string | null;
337
+ };
338
+ export type HomeTitle = {
339
+ transformedLabel: string;
340
+ translatedBaseText: string | null;
341
+ };
342
+ export type HomeSectionData = {
343
+ __typename: 'HomeGenericSectionData';
344
+ headerEntity: {
345
+ __typename: string;
346
+ };
347
+ subtitle: HomeSubtitle;
348
+ title: HomeTitle;
349
+ };
350
+ export type PlaylistAttribute = {
351
+ key: string;
352
+ value: string;
353
+ };
354
+ export type PlaylistOwnerUser = {
355
+ __typename: 'User';
356
+ name: string;
357
+ uri: string;
358
+ };
359
+ export type HomePlaylist = {
360
+ __typename: 'Playlist';
361
+ attributes: PlaylistAttribute[];
362
+ description: string;
363
+ format: string;
364
+ images: {
365
+ items: PlaylistImageItem[];
366
+ };
367
+ name: string;
368
+ ownerV2: {
369
+ data: PlaylistOwnerUser;
370
+ };
371
+ uri: string;
372
+ };
373
+ export type HomePlaylistResponseWrapper = {
374
+ __typename: 'PlaylistResponseWrapper';
375
+ data: HomePlaylist;
376
+ };
377
+ export type HomeSectionItemContent = TrackResponseWrapper | ArtistResponseWrapper | AlbumResponseWrapper | HomePlaylistResponseWrapper;
378
+ export type HomeSectionItem = {
379
+ data: null;
380
+ content: HomeSectionItemContent;
381
+ uri: string;
382
+ };
383
+ export type HomeSectionItems = {
384
+ items: HomeSectionItem[];
385
+ pagingInfo: {
386
+ nextOffset: number | null;
387
+ };
388
+ totalCount: number;
389
+ };
390
+ export type HomeSection = {
391
+ data: HomeSectionData;
392
+ sectionItems: HomeSectionItems;
393
+ uri: string;
394
+ };
395
+ export type PathfinderHomeResponse = {
396
+ data: {
397
+ home: {
398
+ sectionContainer: {
399
+ sections: {
400
+ items: HomeSection[];
401
+ };
402
+ };
403
+ };
404
+ };
405
+ extensions: Record<string, unknown>;
406
+ };
@@ -0,0 +1,2 @@
1
+ // https://github.com/NuclearPlayer/nuclear-plugin-something/blob/268992def6f8e052b21f3014bc7992809bf39e67/src/types.ts#L235 THANK YOU
2
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { AlbumResponse, ArtistResponse, SearchResponse } from './types.js';
2
+ import { App } from '../App.js';
3
+ export * from './types.js';
4
+ export default class YoutubeMusic implements App {
5
+ #private;
6
+ $youtubeClientVersion: string;
7
+ $youtubeClientBuild: string;
8
+ $googVisitorId: string;
9
+ $cookieString: string;
10
+ $getData(): Promise<void>;
11
+ $getCookies(): Promise<void>;
12
+ $getHeaders(): Promise<Record<string, string>>;
13
+ search(query: string): Promise<SearchResponse>;
14
+ getAlbum(uri: string): Promise<AlbumResponse>;
15
+ getArtist(uri: string): Promise<ArtistResponse>;
16
+ }