@audiofool/moondog 0.1.0 → 0.2.0-beta.3
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 +156 -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 +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -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 +166 -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 +328 -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 +9 -6
- 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 +410 -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 +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -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 +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -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 +2536 -692
- 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 +161 -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 +48 -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",
|
|
@@ -407,6 +452,15 @@ export function createAppleMusicCatalogClient({
|
|
|
407
452
|
});
|
|
408
453
|
}
|
|
409
454
|
|
|
455
|
+
// One artist's songs as Apple lists them. The keyword search has no entry for some albums, such as
|
|
456
|
+
// 刘森's 华北浪革, and their album lookup lists no songs, but the artist's lookup has them.
|
|
457
|
+
async function artistSongResults(artistId) {
|
|
458
|
+
const catalogId = cleanArtistCatalogId(artistId);
|
|
459
|
+
const key = `artist-songs|${storefront()}|${catalogId}`;
|
|
460
|
+
return cachedRequest(cache, key, cacheTtlMs, now, () =>
|
|
461
|
+
request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 }));
|
|
462
|
+
}
|
|
463
|
+
|
|
410
464
|
return Object.freeze({
|
|
411
465
|
async searchTracks(query) {
|
|
412
466
|
const cleaned = cleanInputText(
|
|
@@ -415,11 +469,11 @@ export function createAppleMusicCatalogClient({
|
|
|
415
469
|
"apple_music_catalog_query_invalid",
|
|
416
470
|
"catalog query",
|
|
417
471
|
);
|
|
418
|
-
const key = `tracks
|
|
472
|
+
const key = `tracks|${storefront()}|${normalizeForMatch(cleaned)}`;
|
|
419
473
|
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
420
474
|
const results = await request("/search", {
|
|
421
475
|
term: cleaned,
|
|
422
|
-
country:
|
|
476
|
+
country: storefront().toLowerCase(),
|
|
423
477
|
media: "music",
|
|
424
478
|
entity: "song",
|
|
425
479
|
limit: APPLE_MUSIC_CATALOG_LIMITS.trackSearchResultsMax,
|
|
@@ -429,6 +483,36 @@ export function createAppleMusicCatalogClient({
|
|
|
429
483
|
});
|
|
430
484
|
},
|
|
431
485
|
|
|
486
|
+
async searchAlbums(query) {
|
|
487
|
+
const cleaned = cleanInputText(query, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album query");
|
|
488
|
+
const key = `albums|${storefront()}|${normalizeForMatch(cleaned)}`;
|
|
489
|
+
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
490
|
+
const results = await request("/search", { term: cleaned, country: storefront().toLowerCase(), media: "music", entity: "album", limit: 10 });
|
|
491
|
+
return results.map(normalizeAlbum).filter(Boolean);
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
// Every song on one album, in disc and track order.
|
|
496
|
+
async albumTracks(albumId) {
|
|
497
|
+
const catalogId = typeof albumId === "string" && /^\d{1,20}$/u.test(albumId) ? albumId : null;
|
|
498
|
+
if (!catalogId) fail("apple_music_catalog_query_invalid", "The album identity is invalid.");
|
|
499
|
+
const key = `album-tracks|${storefront()}|${catalogId}`;
|
|
500
|
+
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
501
|
+
const results = await request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 });
|
|
502
|
+
const order = (raw) => (Number.isSafeInteger(raw?.discNumber) ? raw.discNumber : 1) * 1000 + (Number.isSafeInteger(raw?.trackNumber) ? raw.trackNumber : 999);
|
|
503
|
+
const song = (raw) => isPlainObject(raw) && raw.kind === "song";
|
|
504
|
+
let songs = results.filter(song);
|
|
505
|
+
const artistId = safeCatalogId(results.find((raw) => isPlainObject(raw) && raw.wrapperType === "collection")?.artistId);
|
|
506
|
+
if (!songs.length && artistId) {
|
|
507
|
+
songs = (await artistSongResults(artistId)).filter((raw) => song(raw) && safeCatalogId(raw.collectionId) === catalogId);
|
|
508
|
+
}
|
|
509
|
+
return songs
|
|
510
|
+
.sort((left, right) => order(left) - order(right))
|
|
511
|
+
.map(normalizeTrack)
|
|
512
|
+
.filter(Boolean);
|
|
513
|
+
});
|
|
514
|
+
},
|
|
515
|
+
|
|
432
516
|
async searchArtists(artistName) {
|
|
433
517
|
const cleaned = cleanInputText(
|
|
434
518
|
artistName,
|
|
@@ -436,11 +520,11 @@ export function createAppleMusicCatalogClient({
|
|
|
436
520
|
"apple_music_catalog_artist_invalid",
|
|
437
521
|
"artist name",
|
|
438
522
|
);
|
|
439
|
-
const key = `artists
|
|
523
|
+
const key = `artists|${storefront()}|${normalizeForMatch(cleaned)}`;
|
|
440
524
|
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
441
525
|
const results = await request("/search", {
|
|
442
526
|
term: cleaned,
|
|
443
|
-
country:
|
|
527
|
+
country: storefront().toLowerCase(),
|
|
444
528
|
media: "music",
|
|
445
529
|
entity: "musicArtist",
|
|
446
530
|
attribute: "artistTerm",
|
|
@@ -458,6 +542,10 @@ export function createAppleMusicCatalogClient({
|
|
|
458
542
|
async artistReleases(artistId) {
|
|
459
543
|
return (await artistCatalog(artistId)).releases;
|
|
460
544
|
},
|
|
545
|
+
|
|
546
|
+
async artistSongs(artistId) {
|
|
547
|
+
return (await artistSongResults(artistId)).map(normalizeTrack).filter(Boolean);
|
|
548
|
+
},
|
|
461
549
|
});
|
|
462
550
|
}
|
|
463
551
|
|
|
@@ -472,7 +560,8 @@ function candidateSummary(artist, releases = []) {
|
|
|
472
560
|
}
|
|
473
561
|
|
|
474
562
|
export function createAppleMusicCatalog({
|
|
475
|
-
|
|
563
|
+
storefront = appleMusicStorefront,
|
|
564
|
+
client = createAppleMusicCatalogClient({ storefront }),
|
|
476
565
|
now = Date.now,
|
|
477
566
|
} = {}) {
|
|
478
567
|
if (
|
|
@@ -484,6 +573,14 @@ export function createAppleMusicCatalog({
|
|
|
484
573
|
throw new TypeError("The Apple Music catalog client is invalid.");
|
|
485
574
|
}
|
|
486
575
|
|
|
576
|
+
// The artists with exactly this name, whose own catalog has what the keyword search misses.
|
|
577
|
+
async function namedArtists(name) {
|
|
578
|
+
const wanted = normalizeForMatch(name);
|
|
579
|
+
return (await client.searchArtists(name))
|
|
580
|
+
.filter((candidate) => normalizeForMatch(candidate.name) === wanted)
|
|
581
|
+
.slice(0, APPLE_MUSIC_CATALOG_LIMITS.exactArtistCandidatesMax);
|
|
582
|
+
}
|
|
583
|
+
|
|
487
584
|
return Object.freeze({
|
|
488
585
|
async searchTracks({ queries, limit = 8 } = {}) {
|
|
489
586
|
if (
|
|
@@ -526,9 +623,9 @@ export function createAppleMusicCatalog({
|
|
|
526
623
|
resultSets.push(await client.searchTracks(query));
|
|
527
624
|
}
|
|
528
625
|
const source = {
|
|
529
|
-
...sourceMetadata(now),
|
|
626
|
+
...sourceMetadata(now, storefront),
|
|
530
627
|
coverage:
|
|
531
|
-
|
|
628
|
+
`Keyword relevance from the Apple Music ${storefront()} storefront. Results are external catalog candidates, not proof of personal fit, novelty, or cross-platform availability.`,
|
|
532
629
|
};
|
|
533
630
|
const tracks = interleaveTrackResults(
|
|
534
631
|
resultSets,
|
|
@@ -544,6 +641,61 @@ export function createAppleMusicCatalog({
|
|
|
544
641
|
};
|
|
545
642
|
},
|
|
546
643
|
|
|
644
|
+
// One named recording with a playable preview: the exact title first, then a version of it.
|
|
645
|
+
async findPreview({ title, artist } = {}) {
|
|
646
|
+
const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "track title");
|
|
647
|
+
const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
|
|
648
|
+
const wantedTitle = normalizeForMatch(cleanedTitle);
|
|
649
|
+
const wantedArtist = normalizeForMatch(cleanedArtist);
|
|
650
|
+
const pick = (tracks) => {
|
|
651
|
+
const results = tracks.filter((track) => track.preview_url && normalizeForMatch(track.artist_credit).includes(wantedArtist));
|
|
652
|
+
return results.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
|
|
653
|
+
results.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
|
|
654
|
+
};
|
|
655
|
+
let track = pick(await client.searchTracks(`${cleanedArtist} ${cleanedTitle}`));
|
|
656
|
+
if (!track && typeof client.artistSongs === "function") {
|
|
657
|
+
for (const named of await namedArtists(cleanedArtist)) {
|
|
658
|
+
track = pick(await client.artistSongs(named.catalog_id));
|
|
659
|
+
if (track) break;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
state: track ? "resolved" : "not_found",
|
|
664
|
+
source: sourceMetadata(now, storefront),
|
|
665
|
+
...(track ? { track } : {}),
|
|
666
|
+
};
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
// One named album's previews, in order: the exact title first, then an edition of it.
|
|
670
|
+
async findAlbumPreviews({ title, artist } = {}) {
|
|
671
|
+
if (typeof client.searchAlbums !== "function" || typeof client.albumTracks !== "function") {
|
|
672
|
+
fail("apple_music_catalog_unavailable", "Album previews are unavailable.");
|
|
673
|
+
}
|
|
674
|
+
const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album title");
|
|
675
|
+
const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
|
|
676
|
+
const wantedTitle = normalizeForMatch(cleanedTitle);
|
|
677
|
+
const wantedArtist = normalizeForMatch(cleanedArtist);
|
|
678
|
+
const pick = (albums) => albums.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
|
|
679
|
+
albums.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
|
|
680
|
+
let album = pick((await client.searchAlbums(`${cleanedArtist} ${cleanedTitle}`))
|
|
681
|
+
.filter((candidate) => normalizeForMatch(candidate.artist_credit).includes(wantedArtist)));
|
|
682
|
+
if (!album) {
|
|
683
|
+
for (const named of await namedArtists(cleanedArtist)) {
|
|
684
|
+
const release = pick(await client.artistReleases(named.catalog_id));
|
|
685
|
+
if (!release) continue;
|
|
686
|
+
album = { catalog_id: release.catalog_id, title: release.title, artist_credit: release.artist_name,
|
|
687
|
+
...(release.catalog_url ? { catalog_url: release.catalog_url } : {}) };
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const tracks = album ? (await client.albumTracks(album.catalog_id)).filter((track) => track.preview_url).slice(0, 30) : [];
|
|
692
|
+
return {
|
|
693
|
+
state: tracks.length ? "resolved" : "not_found",
|
|
694
|
+
source: sourceMetadata(now, storefront),
|
|
695
|
+
...(tracks.length ? { album, tracks } : {}),
|
|
696
|
+
};
|
|
697
|
+
},
|
|
698
|
+
|
|
547
699
|
async findArtistReleasesByCatalogId({
|
|
548
700
|
artistCatalogId,
|
|
549
701
|
limit = 6,
|
|
@@ -565,7 +717,7 @@ export function createAppleMusicCatalog({
|
|
|
565
717
|
"Explicit Apple Music artist lookup is unavailable.",
|
|
566
718
|
);
|
|
567
719
|
}
|
|
568
|
-
const source = sourceMetadata(now);
|
|
720
|
+
const source = sourceMetadata(now, storefront);
|
|
569
721
|
const artist = await client.artistIdentity(catalogId);
|
|
570
722
|
if (!artist) {
|
|
571
723
|
return {
|
|
@@ -635,7 +787,7 @@ export function createAppleMusicCatalog({
|
|
|
635
787
|
);
|
|
636
788
|
}
|
|
637
789
|
|
|
638
|
-
const source = sourceMetadata(now);
|
|
790
|
+
const source = sourceMetadata(now, storefront);
|
|
639
791
|
const normalizedArtist = normalizeForMatch(artist);
|
|
640
792
|
let selected = null;
|
|
641
793
|
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
|
+
}
|