@audiofool/moondog 0.1.0 → 0.2.0-beta.2
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.
- package/LICENSE +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
normalizeAppleMusicLibrary,
|
|
4
4
|
summarizeAppleMusicLibrary,
|
|
5
5
|
} from "./normalize.mjs";
|
|
6
|
-
import { writeAppleMusicImportBatch } from "./import-batch.mjs";
|
|
7
6
|
|
|
8
7
|
export {
|
|
9
8
|
AppleMusicImportError,
|
|
@@ -33,7 +32,6 @@ export {
|
|
|
33
32
|
computeAppleSqliteLogicalDigest,
|
|
34
33
|
defaultAppleMusicProjectionPath,
|
|
35
34
|
defaultAppleMusicProjectionPathFor,
|
|
36
|
-
legacyAppleMusicProjectionPath,
|
|
37
35
|
openAppleMusicSqliteProjection,
|
|
38
36
|
rebuildAppleMusicSqliteProjection,
|
|
39
37
|
} from "./sqlite-projection.mjs";
|
|
@@ -60,17 +58,3 @@ export async function buildAppleMusicLibraryImport(
|
|
|
60
58
|
const parsed = await readAppleMusicLibrary(inputPath, { limits });
|
|
61
59
|
return normalizeAppleMusicLibrary(parsed, { subjectId });
|
|
62
60
|
}
|
|
63
|
-
|
|
64
|
-
export async function importAppleMusicLibraryFile(
|
|
65
|
-
inputPath,
|
|
66
|
-
{ subjectId, outputRoot, outputBoundary, limits } = {},
|
|
67
|
-
) {
|
|
68
|
-
const records = await buildAppleMusicLibraryImport(inputPath, {
|
|
69
|
-
subjectId,
|
|
70
|
-
limits,
|
|
71
|
-
});
|
|
72
|
-
return writeAppleMusicImportBatch(records, {
|
|
73
|
-
outputRoot,
|
|
74
|
-
boundaryRoot: outputBoundary,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
unlink,
|
|
10
10
|
} from "node:fs/promises";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
12
|
|
|
14
13
|
import {
|
|
15
14
|
canonicalizeJson,
|
|
16
15
|
sha256Hex,
|
|
17
16
|
} from "../../../scripts/contract-semantics.mjs";
|
|
18
17
|
import { resolveMoondogStateDirectory } from "../../core/state-directory.mjs";
|
|
18
|
+
import { uuidV5 } from "../../core/uuid-v5.mjs";
|
|
19
19
|
import { AppleMusicImportError } from "./parse-plist.mjs";
|
|
20
20
|
import { listAppleMusicImportBatches } from "./read-batch.mjs";
|
|
21
21
|
import { promoteAppleMusicImportBatches } from "./projection-records.mjs";
|
|
@@ -25,14 +25,6 @@ export const APPLE_SQLITE_PROJECTION_VERSION = 1;
|
|
|
25
25
|
export const APPLE_LIBRARY_SEARCH_HARD_LIMIT = 25;
|
|
26
26
|
export const APPLE_LIBRARY_SEARCH_HARD_OFFSET = 10_000;
|
|
27
27
|
export const APPLE_PROFILE_SUMMARY_HARD_LIMIT = 50;
|
|
28
|
-
// Earlier versions kept the projection inside the source checkout.
|
|
29
|
-
export const legacyAppleMusicProjectionPath = fileURLToPath(
|
|
30
|
-
new URL(
|
|
31
|
-
"../../../data/private/apple-music-library/projection.sqlite",
|
|
32
|
-
import.meta.url,
|
|
33
|
-
),
|
|
34
|
-
);
|
|
35
|
-
|
|
36
28
|
export function defaultAppleMusicProjectionPathFor(environment = process.env) {
|
|
37
29
|
return path.join(resolveMoondogStateDirectory(environment), "apple-music-library", "projection.sqlite");
|
|
38
30
|
}
|
|
@@ -1188,6 +1180,56 @@ class AppleMusicSqliteProjection {
|
|
|
1188
1180
|
};
|
|
1189
1181
|
}
|
|
1190
1182
|
|
|
1183
|
+
getProfileCatalog({ excludePreference = () => false } = {}) {
|
|
1184
|
+
this.#assertOpen();
|
|
1185
|
+
// Aggregate preference evidence per track before joining, so a loved and
|
|
1186
|
+
// rated track contributes once to each facet's track count.
|
|
1187
|
+
const tracks = this.#database.prepare(`SELECT s.track_ref_id, s.title AS label,
|
|
1188
|
+
s.artist_credit, s.release_title AS release, s.genre_label AS genre,
|
|
1189
|
+
s.play_count, s.loved, s.favorited, s.rating_value, s.rating_computed,
|
|
1190
|
+
COALESCE(p.strength, 0) AS preference_strength, p.evidence_id
|
|
1191
|
+
FROM library_search s LEFT JOIN (
|
|
1192
|
+
SELECT subject_id, track_ref_id, MAX(strength) AS strength, MIN(evidence_id) AS evidence_id
|
|
1193
|
+
FROM profile_evidence WHERE claim_dimension = 'taste.track_preference' AND direction = 'supports'
|
|
1194
|
+
GROUP BY subject_id, track_ref_id
|
|
1195
|
+
) p ON p.subject_id = s.subject_id AND p.track_ref_id = s.track_ref_id
|
|
1196
|
+
WHERE s.subject_id = ? ORDER BY preference_strength DESC, s.play_count DESC, s.track_ref_id`)
|
|
1197
|
+
.all(this.#subjectId).map((row) => ({
|
|
1198
|
+
...Object.fromEntries(Object.entries(row).filter(([, value]) => value !== null)),
|
|
1199
|
+
preference_strength: excludePreference(row) ? 0 : row.preference_strength,
|
|
1200
|
+
})).sort((a, b) => b.preference_strength - a.preference_strength ||
|
|
1201
|
+
(b.play_count ?? -1) - (a.play_count ?? -1) || a.track_ref_id.localeCompare(b.track_ref_id));
|
|
1202
|
+
const facets = (field) => {
|
|
1203
|
+
const grouped = new Map();
|
|
1204
|
+
for (const track of tracks) {
|
|
1205
|
+
if (!track[field]?.trim()) continue;
|
|
1206
|
+
const key = normalizedText(track[field]);
|
|
1207
|
+
const item = grouped.get(key) ?? {
|
|
1208
|
+
name: track[field], library_tracks: 0, preferred_tracks: 0, play_count: 0,
|
|
1209
|
+
tracks_with_play_count: 0, preference_strength: 0,
|
|
1210
|
+
evidence_id: uuidV5(`${this.#subjectId}\0apple-${field}\0${key}`, "5d9a8b8d-c66c-547f-98c2-c8a0559f56be"),
|
|
1211
|
+
};
|
|
1212
|
+
item.library_tracks++;
|
|
1213
|
+
if (track.preference_strength > 0) item.preferred_tracks++;
|
|
1214
|
+
item.preference_strength += track.preference_strength;
|
|
1215
|
+
if (track.play_count !== undefined) { item.play_count += track.play_count; item.tracks_with_play_count++; }
|
|
1216
|
+
grouped.set(key, item);
|
|
1217
|
+
}
|
|
1218
|
+
return [...grouped.values()].sort((a, b) => b.preference_strength - a.preference_strength ||
|
|
1219
|
+
b.library_tracks - a.library_tracks || a.name.localeCompare(b.name));
|
|
1220
|
+
};
|
|
1221
|
+
return {
|
|
1222
|
+
coverage: {
|
|
1223
|
+
apple_library_tracks: tracks.length,
|
|
1224
|
+
apple_preferred_tracks: tracks.filter((track) => track.preference_strength > 0).length,
|
|
1225
|
+
apple_tracks_with_play_count: tracks.filter((track) => track.play_count !== undefined).length,
|
|
1226
|
+
apple_aggregate_plays: tracks.reduce((sum, track) => sum + (track.play_count ?? 0), 0),
|
|
1227
|
+
apple_snapshot_at: readMeta(this.#database, "projection.source_timestamp"),
|
|
1228
|
+
},
|
|
1229
|
+
sections: { apple_tracks: tracks, apple_artists: facets("artist_credit"), apple_genres: facets("genre") },
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1191
1233
|
explainProfileEvidence({ evidenceId } = {}) {
|
|
1192
1234
|
this.#assertOpen();
|
|
1193
1235
|
if (!isUuid(evidenceId)) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { uuidV5 } from "../../core/uuid-v5.mjs";
|
|
2
|
+
import { appleMusicStorefront } from "./storefront.mjs";
|
|
2
3
|
|
|
3
4
|
export const APPLE_MUSIC_CATALOG_TRACK_NAMESPACE =
|
|
4
5
|
"2837a09e-e5f0-5aa5-b271-bb99d8b0de20";
|
|
@@ -115,6 +116,45 @@ function safeAppleMusicUrl(value) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// Apple serves 30-second previews only from its own audio host.
|
|
120
|
+
function safePreviewUrl(value) {
|
|
121
|
+
const cleaned = safeText(value, 2_048);
|
|
122
|
+
if (!cleaned) return null;
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(cleaned);
|
|
125
|
+
return url.protocol === "https:" && url.hostname === "audio-ssl.itunes.apple.com" ? url.toString() : null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Cover art comes only from Apple's image hosts, at the size a small player needs.
|
|
132
|
+
function safeArtworkUrl(value) {
|
|
133
|
+
const cleaned = safeText(value, 2_048);
|
|
134
|
+
if (!cleaned) return null;
|
|
135
|
+
try {
|
|
136
|
+
const url = new URL(cleaned);
|
|
137
|
+
if (url.protocol !== "https:" || !url.hostname.endsWith(".mzstatic.com")) return null;
|
|
138
|
+
return url.toString().replace(/\/100x100bb\./u, "/300x300bb.");
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeAlbum(raw) {
|
|
145
|
+
if (!isPlainObject(raw) || raw.wrapperType !== "collection") return null;
|
|
146
|
+
const catalogId = safeCatalogId(raw.collectionId);
|
|
147
|
+
const title = safeText(raw.collectionName, 512);
|
|
148
|
+
const artistCredit = safeText(raw.artistName, 256);
|
|
149
|
+
if (!catalogId || !title || !artistCredit) return null;
|
|
150
|
+
const album = { catalog_id: catalogId, title, artist_credit: artistCredit };
|
|
151
|
+
const catalogUrl = safeAppleMusicUrl(raw.collectionViewUrl);
|
|
152
|
+
const artworkUrl = safeArtworkUrl(raw.artworkUrl100);
|
|
153
|
+
if (catalogUrl) album.catalog_url = catalogUrl;
|
|
154
|
+
if (artworkUrl) album.artwork_url = artworkUrl;
|
|
155
|
+
return album;
|
|
156
|
+
}
|
|
157
|
+
|
|
118
158
|
function releaseDate(value) {
|
|
119
159
|
if (typeof value !== "string") return null;
|
|
120
160
|
const matched = /^(\d{4}-\d{2}-\d{2})T/u.exec(value);
|
|
@@ -214,6 +254,10 @@ function normalizeTrack(raw) {
|
|
|
214
254
|
if (primaryGenre) track.primary_genre = primaryGenre;
|
|
215
255
|
if (catalogUrl) track.catalog_url = catalogUrl;
|
|
216
256
|
if (date) track.release_date = date;
|
|
257
|
+
const previewUrl = safePreviewUrl(raw.previewUrl);
|
|
258
|
+
const artworkUrl = safeArtworkUrl(raw.artworkUrl100);
|
|
259
|
+
if (previewUrl) track.preview_url = previewUrl;
|
|
260
|
+
if (artworkUrl) track.artwork_url = artworkUrl;
|
|
217
261
|
return track;
|
|
218
262
|
}
|
|
219
263
|
|
|
@@ -237,7 +281,7 @@ function deduplicateAndSortReleases(releases) {
|
|
|
237
281
|
});
|
|
238
282
|
}
|
|
239
283
|
|
|
240
|
-
function sourceMetadata(now) {
|
|
284
|
+
function sourceMetadata(now, storefront) {
|
|
241
285
|
const current = now();
|
|
242
286
|
if (!Number.isFinite(current)) {
|
|
243
287
|
fail("apple_music_catalog_clock_invalid", "The catalog clock is invalid.");
|
|
@@ -245,10 +289,10 @@ function sourceMetadata(now) {
|
|
|
245
289
|
return {
|
|
246
290
|
provider: "apple_music",
|
|
247
291
|
catalog: "itunes_search_api",
|
|
248
|
-
storefront:
|
|
292
|
+
storefront: storefront(),
|
|
249
293
|
retrieved_at: new Date(current).toISOString(),
|
|
250
294
|
coverage:
|
|
251
|
-
|
|
295
|
+
`Apple Music ${storefront()} storefront catalog only. This is not a claim about every music platform.`,
|
|
252
296
|
};
|
|
253
297
|
}
|
|
254
298
|
|
|
@@ -353,6 +397,7 @@ export function createAppleMusicCatalogClient({
|
|
|
353
397
|
fetchImpl = globalThis.fetch,
|
|
354
398
|
now = Date.now,
|
|
355
399
|
cacheTtlMs = APPLE_MUSIC_CATALOG_LIMITS.cacheTtlMs,
|
|
400
|
+
storefront = appleMusicStorefront,
|
|
356
401
|
} = {}) {
|
|
357
402
|
if (typeof fetchImpl !== "function") {
|
|
358
403
|
throw new TypeError("A fetch implementation is required.");
|
|
@@ -386,11 +431,11 @@ export function createAppleMusicCatalogClient({
|
|
|
386
431
|
|
|
387
432
|
async function artistCatalog(artistId) {
|
|
388
433
|
const catalogId = cleanArtistCatalogId(artistId);
|
|
389
|
-
const key = `artist-catalog
|
|
434
|
+
const key = `artist-catalog|${storefront()}|${catalogId}`;
|
|
390
435
|
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
391
436
|
const results = await request("/lookup", {
|
|
392
437
|
id: catalogId,
|
|
393
|
-
country:
|
|
438
|
+
country: storefront().toLowerCase(),
|
|
394
439
|
entity: "album",
|
|
395
440
|
limit: APPLE_MUSIC_CATALOG_LIMITS.releasesPerArtistMax,
|
|
396
441
|
sort: "recent",
|
|
@@ -415,11 +460,11 @@ export function createAppleMusicCatalogClient({
|
|
|
415
460
|
"apple_music_catalog_query_invalid",
|
|
416
461
|
"catalog query",
|
|
417
462
|
);
|
|
418
|
-
const key = `tracks
|
|
463
|
+
const key = `tracks|${storefront()}|${normalizeForMatch(cleaned)}`;
|
|
419
464
|
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
420
465
|
const results = await request("/search", {
|
|
421
466
|
term: cleaned,
|
|
422
|
-
country:
|
|
467
|
+
country: storefront().toLowerCase(),
|
|
423
468
|
media: "music",
|
|
424
469
|
entity: "song",
|
|
425
470
|
limit: APPLE_MUSIC_CATALOG_LIMITS.trackSearchResultsMax,
|
|
@@ -429,6 +474,31 @@ export function createAppleMusicCatalogClient({
|
|
|
429
474
|
});
|
|
430
475
|
},
|
|
431
476
|
|
|
477
|
+
async searchAlbums(query) {
|
|
478
|
+
const cleaned = cleanInputText(query, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album query");
|
|
479
|
+
const key = `albums|${storefront()}|${normalizeForMatch(cleaned)}`;
|
|
480
|
+
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
481
|
+
const results = await request("/search", { term: cleaned, country: storefront().toLowerCase(), media: "music", entity: "album", limit: 10 });
|
|
482
|
+
return results.map(normalizeAlbum).filter(Boolean);
|
|
483
|
+
});
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
// Every song on one album, in disc and track order.
|
|
487
|
+
async albumTracks(albumId) {
|
|
488
|
+
const catalogId = typeof albumId === "string" && /^\d{1,20}$/u.test(albumId) ? albumId : null;
|
|
489
|
+
if (!catalogId) fail("apple_music_catalog_query_invalid", "The album identity is invalid.");
|
|
490
|
+
const key = `album-tracks|${storefront()}|${catalogId}`;
|
|
491
|
+
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
492
|
+
const results = await request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 });
|
|
493
|
+
const order = (raw) => (Number.isSafeInteger(raw?.discNumber) ? raw.discNumber : 1) * 1000 + (Number.isSafeInteger(raw?.trackNumber) ? raw.trackNumber : 999);
|
|
494
|
+
return results
|
|
495
|
+
.filter((raw) => isPlainObject(raw) && raw.kind === "song")
|
|
496
|
+
.sort((left, right) => order(left) - order(right))
|
|
497
|
+
.map(normalizeTrack)
|
|
498
|
+
.filter(Boolean);
|
|
499
|
+
});
|
|
500
|
+
},
|
|
501
|
+
|
|
432
502
|
async searchArtists(artistName) {
|
|
433
503
|
const cleaned = cleanInputText(
|
|
434
504
|
artistName,
|
|
@@ -436,11 +506,11 @@ export function createAppleMusicCatalogClient({
|
|
|
436
506
|
"apple_music_catalog_artist_invalid",
|
|
437
507
|
"artist name",
|
|
438
508
|
);
|
|
439
|
-
const key = `artists
|
|
509
|
+
const key = `artists|${storefront()}|${normalizeForMatch(cleaned)}`;
|
|
440
510
|
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
441
511
|
const results = await request("/search", {
|
|
442
512
|
term: cleaned,
|
|
443
|
-
country:
|
|
513
|
+
country: storefront().toLowerCase(),
|
|
444
514
|
media: "music",
|
|
445
515
|
entity: "musicArtist",
|
|
446
516
|
attribute: "artistTerm",
|
|
@@ -472,7 +542,8 @@ function candidateSummary(artist, releases = []) {
|
|
|
472
542
|
}
|
|
473
543
|
|
|
474
544
|
export function createAppleMusicCatalog({
|
|
475
|
-
|
|
545
|
+
storefront = appleMusicStorefront,
|
|
546
|
+
client = createAppleMusicCatalogClient({ storefront }),
|
|
476
547
|
now = Date.now,
|
|
477
548
|
} = {}) {
|
|
478
549
|
if (
|
|
@@ -526,9 +597,9 @@ export function createAppleMusicCatalog({
|
|
|
526
597
|
resultSets.push(await client.searchTracks(query));
|
|
527
598
|
}
|
|
528
599
|
const source = {
|
|
529
|
-
...sourceMetadata(now),
|
|
600
|
+
...sourceMetadata(now, storefront),
|
|
530
601
|
coverage:
|
|
531
|
-
|
|
602
|
+
`Keyword relevance from the Apple Music ${storefront()} storefront. Results are external catalog candidates, not proof of personal fit, novelty, or cross-platform availability.`,
|
|
532
603
|
};
|
|
533
604
|
const tracks = interleaveTrackResults(
|
|
534
605
|
resultSets,
|
|
@@ -544,6 +615,44 @@ export function createAppleMusicCatalog({
|
|
|
544
615
|
};
|
|
545
616
|
},
|
|
546
617
|
|
|
618
|
+
// One named recording with a playable preview: the exact title first, then a version of it.
|
|
619
|
+
async findPreview({ title, artist } = {}) {
|
|
620
|
+
const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "track title");
|
|
621
|
+
const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
|
|
622
|
+
const wantedTitle = normalizeForMatch(cleanedTitle);
|
|
623
|
+
const wantedArtist = normalizeForMatch(cleanedArtist);
|
|
624
|
+
const results = (await client.searchTracks(`${cleanedArtist} ${cleanedTitle}`))
|
|
625
|
+
.filter((track) => track.preview_url && normalizeForMatch(track.artist_credit).includes(wantedArtist));
|
|
626
|
+
const track = results.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
|
|
627
|
+
results.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
|
|
628
|
+
return {
|
|
629
|
+
state: track ? "resolved" : "not_found",
|
|
630
|
+
source: sourceMetadata(now, storefront),
|
|
631
|
+
...(track ? { track } : {}),
|
|
632
|
+
};
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
// One named album's previews, in order: the exact title first, then an edition of it.
|
|
636
|
+
async findAlbumPreviews({ title, artist } = {}) {
|
|
637
|
+
if (typeof client.searchAlbums !== "function" || typeof client.albumTracks !== "function") {
|
|
638
|
+
fail("apple_music_catalog_unavailable", "Album previews are unavailable.");
|
|
639
|
+
}
|
|
640
|
+
const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album title");
|
|
641
|
+
const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
|
|
642
|
+
const wantedTitle = normalizeForMatch(cleanedTitle);
|
|
643
|
+
const wantedArtist = normalizeForMatch(cleanedArtist);
|
|
644
|
+
const albums = (await client.searchAlbums(`${cleanedArtist} ${cleanedTitle}`))
|
|
645
|
+
.filter((candidate) => normalizeForMatch(candidate.artist_credit).includes(wantedArtist));
|
|
646
|
+
const album = albums.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
|
|
647
|
+
albums.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
|
|
648
|
+
const tracks = album ? (await client.albumTracks(album.catalog_id)).filter((track) => track.preview_url).slice(0, 30) : [];
|
|
649
|
+
return {
|
|
650
|
+
state: tracks.length ? "resolved" : "not_found",
|
|
651
|
+
source: sourceMetadata(now, storefront),
|
|
652
|
+
...(tracks.length ? { album, tracks } : {}),
|
|
653
|
+
};
|
|
654
|
+
},
|
|
655
|
+
|
|
547
656
|
async findArtistReleasesByCatalogId({
|
|
548
657
|
artistCatalogId,
|
|
549
658
|
limit = 6,
|
|
@@ -565,7 +674,7 @@ export function createAppleMusicCatalog({
|
|
|
565
674
|
"Explicit Apple Music artist lookup is unavailable.",
|
|
566
675
|
);
|
|
567
676
|
}
|
|
568
|
-
const source = sourceMetadata(now);
|
|
677
|
+
const source = sourceMetadata(now, storefront);
|
|
569
678
|
const artist = await client.artistIdentity(catalogId);
|
|
570
679
|
if (!artist) {
|
|
571
680
|
return {
|
|
@@ -635,7 +744,7 @@ export function createAppleMusicCatalog({
|
|
|
635
744
|
);
|
|
636
745
|
}
|
|
637
746
|
|
|
638
|
-
const source = sourceMetadata(now);
|
|
747
|
+
const source = sourceMetadata(now, storefront);
|
|
639
748
|
const normalizedArtist = normalizeForMatch(artist);
|
|
640
749
|
let selected = null;
|
|
641
750
|
let selectedReleases = [];
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The Music app on this Mac, read and controlled through Apple Events (osascript). Moondog sees what
|
|
2
|
+
// the listener plays there themselves, and the playlist it comes from. Nothing is sent until the app
|
|
3
|
+
// is running, so reading never opens Music. The first read asks the listener to let Moondog (or the
|
|
4
|
+
// terminal it runs in) control Music; until they allow it, reads answer "not_allowed".
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
// The first read waits while macOS asks the listener; later ones answer in well under a second.
|
|
11
|
+
const READ_TIMEOUT_MS = 15_000;
|
|
12
|
+
// A playlist longer than this is read as a window around the playing song.
|
|
13
|
+
const PLAYLIST_WINDOW = 50;
|
|
14
|
+
const PLAYLIST_BEFORE = 5;
|
|
15
|
+
const COLUMN_READ_MAX = 2_000;
|
|
16
|
+
// Covers already drawn, by album, or by song for one without an album.
|
|
17
|
+
const KNOWN_COVERS = 40;
|
|
18
|
+
const COVER_SIZE = 600;
|
|
19
|
+
// After a refusal, Moondog asks macOS again only this often.
|
|
20
|
+
const REFUSED_RETRY_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
23
|
+
|
|
24
|
+
// One JSON answer per run. argv: the action, then its argument.
|
|
25
|
+
const SCRIPT = String.raw`
|
|
26
|
+
function run(argv) {
|
|
27
|
+
const action = argv[0];
|
|
28
|
+
const music = Application("Music");
|
|
29
|
+
if (!music.running()) return JSON.stringify({ running: false });
|
|
30
|
+
if (action === "pause") music.pause();
|
|
31
|
+
else if (action === "resume") music.play();
|
|
32
|
+
else if (action === "next") music.nextTrack();
|
|
33
|
+
else if (action === "previous") music.backTrack();
|
|
34
|
+
else if (action === "seek") music.playerPosition = Number(argv[1]);
|
|
35
|
+
const state = music.playerState();
|
|
36
|
+
const answer = { running: true, state: state };
|
|
37
|
+
if (state === "stopped") return JSON.stringify(answer);
|
|
38
|
+
try {
|
|
39
|
+
const track = music.currentTrack.properties();
|
|
40
|
+
answer.track = { id: track.persistentID, title: track.name, artist: track.artist, album: track.album,
|
|
41
|
+
albumArtist: track.albumArtist, duration: track.duration };
|
|
42
|
+
} catch (error) {}
|
|
43
|
+
try { answer.stream = music.currentStreamTitle(); } catch (error) {}
|
|
44
|
+
try { answer.position = music.playerPosition(); } catch (error) {}
|
|
45
|
+
try { answer.shuffle = music.shuffleEnabled(); answer.repeat = music.songRepeat(); } catch (error) {}
|
|
46
|
+
try {
|
|
47
|
+
const playlist = music.currentPlaylist;
|
|
48
|
+
answer.playlist = { id: playlist.persistentID(), name: playlist.name() };
|
|
49
|
+
try { answer.playlist.special = playlist.specialKind(); } catch (error) {}
|
|
50
|
+
if (action === "tracks") {
|
|
51
|
+
const tracks = playlist.tracks;
|
|
52
|
+
const ids = tracks.persistentID();
|
|
53
|
+
answer.playlist.count = ids.length;
|
|
54
|
+
const at = answer.track ? ids.indexOf(answer.track.id) : -1;
|
|
55
|
+
const start = ids.length <= ${PLAYLIST_WINDOW} || at < 0 ? 0 : Math.max(0, Math.min(at - ${PLAYLIST_BEFORE}, ids.length - ${PLAYLIST_WINDOW}));
|
|
56
|
+
const end = Math.min(ids.length, start + ${PLAYLIST_WINDOW});
|
|
57
|
+
answer.playlist.index = at;
|
|
58
|
+
answer.playlist.start = start;
|
|
59
|
+
answer.playlist.tracks = [];
|
|
60
|
+
// A playlist of ordinary length comes back fastest a column at a time; a long one, song by song.
|
|
61
|
+
if (ids.length <= ${COLUMN_READ_MAX}) {
|
|
62
|
+
const titles = tracks.name(), artists = tracks.artist(), albums = tracks.album(), durations = tracks.duration();
|
|
63
|
+
for (let index = start; index < end; index++) {
|
|
64
|
+
answer.playlist.tracks.push({ id: ids[index], title: titles[index], artist: artists[index], album: albums[index], duration: durations[index] });
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
for (let index = start; index < end; index++) {
|
|
68
|
+
const entry = tracks[index].properties();
|
|
69
|
+
answer.playlist.tracks.push({ id: entry.persistentID, title: entry.name, artist: entry.artist, album: entry.album, duration: entry.duration });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {}
|
|
74
|
+
return JSON.stringify(answer);
|
|
75
|
+
}`;
|
|
76
|
+
|
|
77
|
+
// Music hands a cover over only as AppleScript data, which JavaScript for Automation cannot write.
|
|
78
|
+
// argv: the file to write, then the persistent id of the playing song or of one in its playlist.
|
|
79
|
+
const COVER_SCRIPT = String.raw`
|
|
80
|
+
on run argv
|
|
81
|
+
if application "Music" is not running then return "none"
|
|
82
|
+
set wanted to item 2 of argv
|
|
83
|
+
tell application "Music"
|
|
84
|
+
try
|
|
85
|
+
if persistent ID of current track is wanted then
|
|
86
|
+
set chosen to current track
|
|
87
|
+
else
|
|
88
|
+
set chosen to first track of current playlist whose persistent ID is wanted
|
|
89
|
+
end if
|
|
90
|
+
set cover to raw data of artwork 1 of chosen
|
|
91
|
+
on error
|
|
92
|
+
return "none"
|
|
93
|
+
end try
|
|
94
|
+
end tell
|
|
95
|
+
set target to open for access (POSIX file (item 1 of argv)) with write permission
|
|
96
|
+
set eof target to 0
|
|
97
|
+
write cover to target
|
|
98
|
+
close access target
|
|
99
|
+
return "ok"
|
|
100
|
+
end run`;
|
|
101
|
+
|
|
102
|
+
function osascript(args, { timeout = READ_TIMEOUT_MS } = {}) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
execFile("/usr/bin/osascript", args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
105
|
+
if (!error) return resolve(String(stdout).trim());
|
|
106
|
+
// -1743: the listener has not let this app control Music (System Settings, Privacy & Security, Automation).
|
|
107
|
+
if (/-1743|Not authori[sz]ed/iu.test(String(stderr))) {
|
|
108
|
+
return reject(failure("music_app_not_allowed", "Moondog is not allowed to control the Music app. Turn it on in System Settings, Privacy & Security, Automation."));
|
|
109
|
+
}
|
|
110
|
+
reject(failure("music_app_failed", `The Music app did not answer: ${String(stderr || error.message).trim().slice(0, 200)}`));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const text = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
|
|
116
|
+
const milliseconds = (seconds) => (Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : null);
|
|
117
|
+
|
|
118
|
+
function songOf(track) {
|
|
119
|
+
return {
|
|
120
|
+
id: text(track.id),
|
|
121
|
+
title: text(track.title) ?? "",
|
|
122
|
+
artists: text(track.artist) ? [text(track.artist)] : [],
|
|
123
|
+
album: text(track.album),
|
|
124
|
+
duration_ms: milliseconds(track.duration),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A radio station names its song only in the stream's title, "Artist - Title".
|
|
129
|
+
function streamSongOf(answer) {
|
|
130
|
+
const stream = text(answer.stream);
|
|
131
|
+
if (!stream) return answer.track ? songOf(answer.track) : null;
|
|
132
|
+
const [artist, ...title] = stream.split(" - ");
|
|
133
|
+
return title.length ? { id: null, title: title.join(" - "), artists: [artist], album: text(answer.track?.title), duration_ms: null }
|
|
134
|
+
: { id: null, title: stream, artists: [], album: text(answer.track?.title), duration_ms: null };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What the Music app plays: { state, song, position_ms, shuffle, repeat, playlist }.
|
|
139
|
+
* state is "playing", "paused" or "stopped"; "not_running" while the app is closed,
|
|
140
|
+
* "not_allowed" until the listener lets Moondog control it, "unavailable" off macOS.
|
|
141
|
+
* playlist.tracks, with the playing song's place among them, comes only when asked for.
|
|
142
|
+
*/
|
|
143
|
+
function nowPlayingOf(answer) {
|
|
144
|
+
if (!answer?.running) return { state: "not_running" };
|
|
145
|
+
const state = answer.state === "playing" || answer.state === "fast forwarding" || answer.state === "rewinding" ? "playing"
|
|
146
|
+
: answer.state === "paused" ? "paused" : "stopped";
|
|
147
|
+
const song = state === "stopped" ? null : streamSongOf(answer);
|
|
148
|
+
if (!song?.title) return { state: "stopped" };
|
|
149
|
+
const playlist = answer.playlist && text(answer.playlist.name) ? {
|
|
150
|
+
id: text(answer.playlist.id),
|
|
151
|
+
name: text(answer.playlist.name),
|
|
152
|
+
// "Library" or "Music" when the song plays from the whole library rather than a playlist.
|
|
153
|
+
library: ["Library", "Music"].includes(answer.playlist.special),
|
|
154
|
+
...(Number.isInteger(answer.playlist.count) ? { tracks_total: answer.playlist.count } : {}),
|
|
155
|
+
...(Array.isArray(answer.playlist.tracks) ? {
|
|
156
|
+
position: answer.playlist.index >= 0 ? answer.playlist.index + 1 : null,
|
|
157
|
+
first_position: answer.playlist.start + 1,
|
|
158
|
+
tracks: answer.playlist.tracks.map(songOf),
|
|
159
|
+
} : {}),
|
|
160
|
+
} : null;
|
|
161
|
+
return {
|
|
162
|
+
state,
|
|
163
|
+
song,
|
|
164
|
+
position_ms: milliseconds(answer.position) ?? 0,
|
|
165
|
+
shuffle: answer.shuffle === true,
|
|
166
|
+
repeat: answer.repeat === "one" ? "track" : answer.repeat === "all" ? "context" : "off",
|
|
167
|
+
playlist,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createMusicAppPlayer({ platform = process.platform, run = osascript, now = Date.now } = {}) {
|
|
172
|
+
const available = platform === "darwin";
|
|
173
|
+
let refusedAt = null;
|
|
174
|
+
const covers = new Map();
|
|
175
|
+
|
|
176
|
+
async function ask(action, argument) {
|
|
177
|
+
if (!available) return null;
|
|
178
|
+
if (refusedAt !== null && now() - refusedAt < REFUSED_RETRY_MS) throw failure("music_app_not_allowed", "Moondog is not allowed to control the Music app. Turn it on in System Settings, Privacy & Security, Automation.");
|
|
179
|
+
try {
|
|
180
|
+
const answer = JSON.parse(await run(["-l", "JavaScript", "-e", SCRIPT, action, ...(argument === undefined ? [] : [String(argument)])]));
|
|
181
|
+
refusedAt = null;
|
|
182
|
+
return answer;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error?.code === "music_app_not_allowed") refusedAt = now();
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const read = async (action, argument) => {
|
|
189
|
+
try {
|
|
190
|
+
return available ? nowPlayingOf(await ask(action, argument)) : { state: "unavailable" };
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.code === "music_app_not_allowed") return { state: "not_allowed", reason: error.message };
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
available,
|
|
199
|
+
nowPlaying: ({ tracks = false } = {}) => read(tracks ? "tracks" : "read"),
|
|
200
|
+
pause: () => read("pause"),
|
|
201
|
+
resume: () => read("resume"),
|
|
202
|
+
next: () => read("next"),
|
|
203
|
+
previous: () => read("previous"),
|
|
204
|
+
seek: ({ positionMs = 0 } = {}) => read("seek", Math.max(0, positionMs) / 1000),
|
|
205
|
+
|
|
206
|
+
// The cover of the playing song, or of one in its playlist, as a data URL. It is read from Music
|
|
207
|
+
// and scaled down once per album.
|
|
208
|
+
async cover(song) {
|
|
209
|
+
if (!available || !song?.id) return null;
|
|
210
|
+
const key = song.album ? `${song.artists?.[0] ?? ""}\n${song.album}` : song.id;
|
|
211
|
+
if (covers.has(key)) return covers.get(key);
|
|
212
|
+
const folder = await mkdtemp(path.join(tmpdir(), "moondog-cover-"));
|
|
213
|
+
let url = null;
|
|
214
|
+
try {
|
|
215
|
+
const original = path.join(folder, "cover");
|
|
216
|
+
const scaled = path.join(folder, "cover.jpg");
|
|
217
|
+
if (await run(["-e", COVER_SCRIPT, original, song.id]) === "ok") {
|
|
218
|
+
await new Promise((resolve, reject) => execFile("/usr/bin/sips", ["-s", "format", "jpeg", "-Z", String(COVER_SIZE), original, "--out", scaled],
|
|
219
|
+
{ timeout: READ_TIMEOUT_MS }, (error) => (error ? reject(error) : resolve())));
|
|
220
|
+
url = `data:image/jpeg;base64,${(await readFile(scaled)).toString("base64")}`;
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
url = null;
|
|
224
|
+
} finally {
|
|
225
|
+
await rm(folder, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
covers.set(key, url);
|
|
228
|
+
if (covers.size > KNOWN_COVERS) covers.delete(covers.keys().next().value);
|
|
229
|
+
return url;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The Apple Music storefront the catalog searches: the listener's own account's when the desktop
|
|
2
|
+
// app's MusicKit helper reports it, otherwise the Mac's region as the desktop app passes it, otherwise
|
|
3
|
+
// the region of the process's locale, otherwise the US.
|
|
4
|
+
// Catalog ids and previews differ between storefronts, so whole songs need the account's one.
|
|
5
|
+
|
|
6
|
+
const code = (value) => (typeof value === "string" && /^[a-z]{2}$/iu.test(value) ? value.toUpperCase() : null);
|
|
7
|
+
|
|
8
|
+
function localeRegion() {
|
|
9
|
+
try {
|
|
10
|
+
return code(new Intl.Locale(Intl.DateTimeFormat().resolvedOptions().locale).maximize().region);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let account = null;
|
|
17
|
+
|
|
18
|
+
/** The storefront's two-letter country code, upper case, such as "US". */
|
|
19
|
+
export function appleMusicStorefront() {
|
|
20
|
+
return account ?? code(process.env.MOONDOG_SYSTEM_REGION) ?? localeRegion() ?? "US";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Records the listener's account storefront; an invalid code leaves the current one. */
|
|
24
|
+
export function setAppleMusicStorefront(value) {
|
|
25
|
+
const valid = code(value);
|
|
26
|
+
if (valid) account = valid;
|
|
27
|
+
return Boolean(valid);
|
|
28
|
+
}
|