@chronicle.app/shazam 0.0.0-stage → 0.4.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # @chronicle.app/shazam
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The songs you identified with Shazam, from the library macOS keeps for Music Recognition.
4
+
5
+ ## Usage
6
+
7
+ ```sh
8
+ chronicle extract shazam
9
+ chronicle extract shazam --since 2024-01-01 --until 2025-01-01
10
+ chronicle extract shazam --input "/path/to/ShazamLibrary.sqlite"
11
+ chronicle extract shazam --link-artists
12
+ ```
13
+
14
+ The library is `~/Library/Application Support/com.apple.shazamd/ShazamLibrary.sqlite`, opened read-only with built-in `node:sqlite`. macOS keeps it in step with the Shazam library in your iCloud account, so it holds what you identified on every device: the Shazam app on your iPhone as well as Music Recognition in Control Center. It syncs only when Music Recognition is turned on in your iCloud settings. If the library is empty, open Music Recognition in Control Center to load your history.
15
+
16
+ Shazams come out newest first, and `--since` and `--until` bound when each song was identified.
17
+
18
+ A run reads only the library, offline. With `--link-artists`, it also looks up each song's main artist with Apple's public lookup API, which needs no sign-in: two requests for every 200 Shazams.
19
+
20
+ ## Schema
21
+
22
+ | Shazam thing | Chronicle node | Key | Properties |
23
+ | ------------------ | ---------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | Identifying a song | `ListenAction` | `@type`, `source`, `sourceId` (the recognition ID) | `agent` (you), `object` (the song), `timestamp` (when it was identified), `location` (a `Location` with its `latitude` and `longitude`) |
25
+ | The song | `MusicRecording` | `@type`, `source` (`shazam`), `sourceId` (the Shazam track key) | `name`, `artist`, `inAlbum`, `duration`, `genre`, `datePublished` (the release date), `emblem` (the cover), `sameAs` (its ISRC and Apple Music song) |
26
+ | The artist | `MusicGroup` | `@type`, `source` (`apple-music`), `sourceId` (its artist ID) | `name`, `url`; with `--link-artists` |
27
+ | The artist line | `MusicGroup` | `@type`, `source` (`shazam`), `name` | `name`; without `--link-artists`, or when Apple Music has no artist for the song |
28
+ | The album | `MusicAlbum` | `@type`, `source` (`apple-music`), `sourceId` (its album ID) | `name` |
29
+ | You | `Person` | `@type`, `source` (`icloud`), `sourceId` (your account's DSID) | `handle` (your Apple Account email); `sameAs` `@me` |
30
+
31
+ `SHAPES.md` sketches the output, generated from the tests.
32
+
33
+ ### Decisions
34
+
35
+ **Identifying a song is listening to it.** A Shazam is a `ListenAction` of the song, keyed by its recognition ID, which the Shazam app gives it on every device. A sync can leave two copies of one Shazam in the library; they're one action, from the most recently modified copy. A Shazam with no recognition ID is known by its sync ID. Shazaming a song again is another action with the same song.
36
+
37
+ **A song is Shazam's copy, the same song as its ISRC and Apple Music song.** The song is keyed by its Shazam track key and named as Shazam names it, with the artist line as Shazam words it (`Artist Feat. Guest`) unless its artist is linked. It's `sameAs` its ISRC, in the `isrc` namespace, which every service's copy of the recording shares, and its Apple Music song, in the `apple-music` namespace, with its link. The album, release date, duration, genres, and cover are Apple Music's, so a Shazam with no Apple Music match has none of them. The album is keyed by the album ID in the Apple Music link. Apple's root genre, `Music`, which every song has, is left out. Apple Music covers are stored as HEIC, which most browsers can't show, so the cover is the same size as a JPEG from Apple's image server. The plugin declares a `deepLinks` template for `MusicRecording` (`https://www.shazam.com/track/<key>`).
38
+
39
+ **An artist is Apple Music's when it's linked, and otherwise Shazam's artist line.** Shazam gives no artist ID, so without `--link-artists` the artist line is a `MusicGroup` keyed by its name. With it, the extractor looks up each song with Apple Music, a batch at a time, and the artist is a `MusicGroup` keyed by the Apple Music ID of the song's main artist, named as Apple Music names that artist. A song's lookup gives the main artist's ID beside the song's whole credit line (`Artist & Guest`), so the artist's own name (`Artist`) comes from a second lookup of the ID. Featured and other credited artists are left out. A Shazam with no Apple Music match keeps its artist line. Lookups are made in the store of the song's Apple Music link. When Apple Music limits lookups, the extractor waits and tries again; when it can't reach Apple Music, the rest of the run keeps artist lines and says so.
40
+
41
+ **Where you were, when the device knew.** A Shazam made without a location fix records 0, 0 or -180, -180, which is left out.
42
+
43
+ **The agent is your iCloud account,** whose library this is, from `@chronicle.app/icloud`. It's omitted when no account can be read. An explicit `account` object (or null) describes a copied library without a host account lookup.
44
+
45
+ **The archived fields are decoded in the plugin.** Core Data keeps a song's genres and Apple Music response as NSKeyedArchiver archives in binary property lists. `archive.ts` reads them. A field that doesn't decode is logged and left out; the Shazam is kept.
46
+
47
+ ## Tests
48
+
49
+ The tests are smoke tests over a synthetic `ShazamLibrary.sqlite`, with archives written by the fixture, and pass an explicit iCloud account. They never read the host's Shazam library or iCloud account, and never reach Apple Music: `--link-artists`'s lookups aren't tested.
@@ -0,0 +1,30 @@
1
+ export declare const LOOKUP_BATCH = 200;
2
+ export interface AppleMusicArtist {
3
+ id: string;
4
+ name: string;
5
+ url?: string;
6
+ }
7
+ /** A song to look up: its Apple Music ID and the store (`ca`, `us`) its link is in. */
8
+ export interface AppleMusicSong {
9
+ id: string;
10
+ country: string;
11
+ }
12
+ interface LookupLog {
13
+ info(message: string): void;
14
+ /** Called once, when a lookup fails; the run stops looking up after it. */
15
+ failure(error: Error): void;
16
+ }
17
+ export declare class AppleMusicLookup {
18
+ private readonly artistOfSong;
19
+ private readonly artists;
20
+ private failed;
21
+ private readonly log;
22
+ constructor(log: LookupLog);
23
+ /** Each song's main artist, by song ID. Songs Apple has no artist for are absent. */
24
+ artistsOf(songs: AppleMusicSong[]): Promise<Map<string, AppleMusicArtist>>;
25
+ private lookup;
26
+ }
27
+ /** The store an Apple Music link is in (`https://music.apple.com/ca/...`), or the US store. */
28
+ export declare function countryOf(link: string | null): string;
29
+ export {};
30
+ //# sourceMappingURL=appleMusic.d.ts.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Apple's public lookup API (the iTunes Search API's `lookup`), which needs
3
+ * no sign-in. A song's lookup gives its main artist's ID beside the song's
4
+ * whole credit line (`Artist & Guest`), so the artist's own name (`Artist`)
5
+ * comes from looking up that ID.
6
+ */
7
+ const LOOKUP_URL = 'https://itunes.apple.com/lookup';
8
+ // Apple answers for up to 200 IDs a request.
9
+ export const LOOKUP_BATCH = 200;
10
+ // Apple throttles the API with a 403 or 429; wait longer each time, then give up.
11
+ const BACKOFF_SECONDS = [10, 30, 60];
12
+ export class AppleMusicLookup {
13
+ // Remembered for the run, including what Apple had no answer for (null).
14
+ artistOfSong = new Map();
15
+ artists = new Map();
16
+ failed = false;
17
+ log;
18
+ constructor(log) {
19
+ this.log = log;
20
+ }
21
+ /** Each song's main artist, by song ID. Songs Apple has no artist for are absent. */
22
+ async artistsOf(songs) {
23
+ const newSongs = songs.filter(song => !this.artistOfSong.has(song.id));
24
+ for (const [country, ids] of byCountry(newSongs)) {
25
+ for (const result of await this.lookup(ids, country)) {
26
+ if (result.wrapperType === 'track' && result.trackId && result.artistId) {
27
+ this.artistOfSong.set(String(result.trackId), String(result.artistId));
28
+ }
29
+ }
30
+ if (this.failed)
31
+ return new Map();
32
+ for (const id of ids)
33
+ if (!this.artistOfSong.has(id))
34
+ this.artistOfSong.set(id, null);
35
+ }
36
+ const newArtists = songs
37
+ .map(song => ({ id: this.artistOfSong.get(song.id), country: song.country }))
38
+ .filter((artist) => Boolean(artist.id) && !this.artists.has(artist.id));
39
+ for (const [country, ids] of byCountry(newArtists)) {
40
+ for (const result of await this.lookup(ids, country)) {
41
+ if (result.wrapperType === 'artist' && result.artistId && result.artistName) {
42
+ this.artists.set(String(result.artistId), {
43
+ id: String(result.artistId),
44
+ name: result.artistName,
45
+ ...(result.artistLinkUrl && { url: withoutQuery(result.artistLinkUrl) }),
46
+ });
47
+ }
48
+ }
49
+ if (this.failed)
50
+ return new Map();
51
+ for (const id of ids)
52
+ if (!this.artists.has(id))
53
+ this.artists.set(id, null);
54
+ }
55
+ const found = new Map();
56
+ for (const song of songs) {
57
+ const artist = this.artists.get(this.artistOfSong.get(song.id) ?? '');
58
+ if (artist)
59
+ found.set(song.id, artist);
60
+ }
61
+ return found;
62
+ }
63
+ async lookup(ids, country) {
64
+ if (this.failed || ids.length === 0)
65
+ return [];
66
+ const url = `${LOOKUP_URL}?id=${ids.join(',')}&country=${encodeURIComponent(country)}`;
67
+ try {
68
+ for (let attempt = 0;; attempt++) {
69
+ const response = await fetch(url);
70
+ if (response.ok)
71
+ return (await response.json()).results ?? [];
72
+ const throttled = response.status === 403 || response.status === 429;
73
+ if (!throttled || attempt >= BACKOFF_SECONDS.length) {
74
+ throw new Error(`Apple Music's lookup answered ${response.status}`);
75
+ }
76
+ this.log.info(`Apple Music is limiting lookups; waiting ${BACKOFF_SECONDS[attempt]}s`);
77
+ await sleep(BACKOFF_SECONDS[attempt] * 1000);
78
+ }
79
+ }
80
+ catch (error) {
81
+ this.failed = true;
82
+ this.log.failure(error);
83
+ return [];
84
+ }
85
+ }
86
+ }
87
+ /** IDs grouped by store, in batches Apple answers for in one request. */
88
+ function byCountry(items) {
89
+ const groups = new Map();
90
+ for (const { id, country } of items) {
91
+ const ids = groups.get(country) ?? new Set();
92
+ ids.add(id);
93
+ groups.set(country, ids);
94
+ }
95
+ const batches = [];
96
+ for (const [country, ids] of groups) {
97
+ const all = [...ids];
98
+ for (let i = 0; i < all.length; i += LOOKUP_BATCH) {
99
+ batches.push([country, all.slice(i, i + LOOKUP_BATCH)]);
100
+ }
101
+ }
102
+ return batches;
103
+ }
104
+ /** The store an Apple Music link is in (`https://music.apple.com/ca/...`), or the US store. */
105
+ export function countryOf(link) {
106
+ return link?.match(/^https:\/\/music\.apple\.com\/([a-z]{2})\//)?.[1] ?? 'us';
107
+ }
108
+ function withoutQuery(link) {
109
+ return link.split('?')[0];
110
+ }
111
+ function sleep(ms) {
112
+ return new Promise(resolve => {
113
+ setTimeout(resolve, ms);
114
+ });
115
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Core Data keeps a transformable attribute, such as a track's genres, as an
3
+ * NSKeyedArchiver archive in a binary property list (`bplist00`). This reads
4
+ * the archives shazamd writes: arrays, dictionaries, strings, numbers, dates,
5
+ * and data. Other classes come back as `null`.
6
+ */
7
+ /** A reference to an entry in an archive's `$objects`. */
8
+ declare class Uid {
9
+ readonly index: number;
10
+ constructor(index: number);
11
+ }
12
+ type PlistValue = null | boolean | number | string | Date | Uint8Array | Uid | PlistValue[] | {
13
+ [key: string]: PlistValue;
14
+ };
15
+ /** Parse a binary property list. */
16
+ export declare function parseBinaryPlist(bytes: Uint8Array): PlistValue;
17
+ /** Decode an NSKeyedArchiver archive to plain values. */
18
+ export declare function unarchive(bytes: Uint8Array): unknown;
19
+ export {};
20
+ //# sourceMappingURL=archive.d.ts.map
@@ -0,0 +1,127 @@
1
+ import { APPLE_EPOCH_OFFSET_SECONDS } from '@chronicle.app/etl-sqlite';
2
+ /**
3
+ * Core Data keeps a transformable attribute, such as a track's genres, as an
4
+ * NSKeyedArchiver archive in a binary property list (`bplist00`). This reads
5
+ * the archives shazamd writes: arrays, dictionaries, strings, numbers, dates,
6
+ * and data. Other classes come back as `null`.
7
+ */
8
+ /** A reference to an entry in an archive's `$objects`. */
9
+ class Uid {
10
+ index;
11
+ constructor(index) {
12
+ this.index = index;
13
+ }
14
+ }
15
+ // Nested containers deeper than this are a malformed (or cyclic) file.
16
+ const MAX_DEPTH = 64;
17
+ /** Parse a binary property list. */
18
+ export function parseBinaryPlist(bytes) {
19
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
20
+ if (buf.length < 40 || buf.toString('latin1', 0, 8) !== 'bplist00') {
21
+ throw new Error('Not a binary property list');
22
+ }
23
+ const trailer = buf.length - 32;
24
+ const offsetSize = buf[trailer + 6];
25
+ const refSize = buf[trailer + 7];
26
+ const count = Number(buf.readBigUInt64BE(trailer + 8));
27
+ const top = Number(buf.readBigUInt64BE(trailer + 16));
28
+ const offsetTable = Number(buf.readBigUInt64BE(trailer + 24));
29
+ const uint = (at, size) => {
30
+ let n = 0;
31
+ for (let i = 0; i < size; i++)
32
+ n = n * 256 + buf[at + i];
33
+ return n;
34
+ };
35
+ const read = (index, depth) => {
36
+ if (index >= count || depth > MAX_DEPTH)
37
+ throw new Error('Malformed binary property list');
38
+ const at = uint(offsetTable + index * offsetSize, offsetSize);
39
+ // A marker byte holds the object's type in its high four bits and its
40
+ // size or count in its low four.
41
+ const type = Math.floor(buf[at] / 16);
42
+ const info = buf[at] % 16;
43
+ // A count of 15 or more follows the marker as an integer object.
44
+ let length = info;
45
+ let start = at + 1;
46
+ if (info === 0xf) {
47
+ const size = 2 ** (buf[at + 1] % 16);
48
+ length = uint(at + 2, size);
49
+ start = at + 2 + size;
50
+ }
51
+ const refs = (offset, n) => Array.from({ length: n }, (_, i) => read(uint(start + (offset + i) * refSize, refSize), depth + 1));
52
+ switch (type) {
53
+ case 0x0:
54
+ return info === 0x9 ? true : info === 0x8 ? false : null;
55
+ case 0x1:
56
+ return info === 3 ? Number(buf.readBigInt64BE(at + 1)) : uint(at + 1, 2 ** info);
57
+ case 0x2:
58
+ return info === 2 ? buf.readFloatBE(at + 1) : buf.readDoubleBE(at + 1);
59
+ case 0x3:
60
+ return new Date((buf.readDoubleBE(at + 1) + APPLE_EPOCH_OFFSET_SECONDS) * 1000);
61
+ case 0x4:
62
+ return buf.subarray(start, start + length);
63
+ case 0x5:
64
+ return buf.toString('latin1', start, start + length);
65
+ case 0x6:
66
+ // UTF-16 big-endian; Node decodes only little-endian.
67
+ return Buffer.from(buf.subarray(start, start + length * 2))
68
+ .swap16()
69
+ .toString('utf16le');
70
+ case 0x8:
71
+ return new Uid(uint(at + 1, info + 1));
72
+ case 0xa:
73
+ case 0xc:
74
+ return refs(0, length);
75
+ case 0xd: {
76
+ const keys = refs(0, length);
77
+ const values = refs(length, length);
78
+ return Object.fromEntries(keys.map((key, i) => [String(key), values[i]]));
79
+ }
80
+ default:
81
+ throw new Error(`Unsupported binary property list type 0x${type.toString(16)}`);
82
+ }
83
+ };
84
+ return read(top, 0);
85
+ }
86
+ /** Decode an NSKeyedArchiver archive to plain values. */
87
+ export function unarchive(bytes) {
88
+ const archive = parseBinaryPlist(bytes);
89
+ const objects = archive?.$objects;
90
+ if (archive?.$archiver !== 'NSKeyedArchiver' || !Array.isArray(objects)) {
91
+ throw new Error('Not a keyed archive');
92
+ }
93
+ const resolve = (value, depth) => {
94
+ if (depth > MAX_DEPTH)
95
+ throw new Error('Malformed keyed archive');
96
+ if (value instanceof Uid) {
97
+ // Entry 0 is `$null`, the archive's nil.
98
+ if (value.index === 0)
99
+ return null;
100
+ return resolve(objects[value.index], depth + 1);
101
+ }
102
+ if (value === null || typeof value !== 'object' || value instanceof Date)
103
+ return value;
104
+ if (value instanceof Uint8Array)
105
+ return value;
106
+ if (Array.isArray(value))
107
+ return value.map(item => resolve(item, depth + 1));
108
+ if (Array.isArray(value['NS.keys']) && Array.isArray(value['NS.objects'])) {
109
+ const values = value['NS.objects'];
110
+ return Object.fromEntries(value['NS.keys'].map((key, i) => [
111
+ String(resolve(key, depth + 1)),
112
+ resolve(values[i], depth + 1),
113
+ ]));
114
+ }
115
+ if (Array.isArray(value['NS.objects']))
116
+ return resolve(value['NS.objects'], depth + 1);
117
+ if ('NS.string' in value)
118
+ return resolve(value['NS.string'], depth + 1);
119
+ if ('NS.data' in value)
120
+ return resolve(value['NS.data'], depth + 1);
121
+ if (typeof value['NS.time'] === 'number') {
122
+ return new Date((value['NS.time'] + APPLE_EPOCH_OFFSET_SECONDS) * 1000);
123
+ }
124
+ return null;
125
+ };
126
+ return resolve(archive.$top?.root ?? null, 0);
127
+ }
@@ -0,0 +1,66 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
+ import { type AppleMusicArtist } from '../appleMusic.js';
4
+ import ShazamTransformer from './ShazamTransformer.js';
5
+ /** One Shazam as the library row gives it, named after the row's Core Data attributes. */
6
+ export interface Shazam {
7
+ recognitionID: string;
8
+ syncID: string | null;
9
+ /** When the song was recognized, in UTC. */
10
+ date: string;
11
+ /** The title and artist line as Shazam words them. */
12
+ title: string | null;
13
+ subtitle: string | null;
14
+ shazamKey: string | null;
15
+ shazamURL: string | null;
16
+ providerName: string | null;
17
+ latitude: number | null;
18
+ longitude: number | null;
19
+ artworkURL: string | null;
20
+ appleMusicID: string | null;
21
+ appleMusicURL: string | null;
22
+ isrc: string | null;
23
+ albumName: string | null;
24
+ /** A day, `YYYY-MM-DD`. */
25
+ releaseDate: string | null;
26
+ explicit: boolean | null;
27
+ genres: string[];
28
+ /** Apple Music's song resource (`id`, `attributes`), decoded from `rawSongResponse`. */
29
+ song: {
30
+ id?: string;
31
+ attributes?: {
32
+ [key: string]: unknown;
33
+ };
34
+ } | null;
35
+ /** The song's main artist on Apple Music, looked up with `linkArtists`. */
36
+ appleMusicArtist?: AppleMusicArtist;
37
+ }
38
+ /**
39
+ * The Shazam library that macOS keeps for Music Recognition (`shazamd`). It
40
+ * mirrors the iCloud library the Shazam app syncs to, so it holds the songs
41
+ * recognized on every device signed in to the account.
42
+ */
43
+ export declare class ShazamExtractor extends SqliteExtractor<typeof ShazamExtractor> {
44
+ static source: string;
45
+ static description: string;
46
+ static delivery: "local";
47
+ static strategy: string;
48
+ static recordTypes: string[];
49
+ static default: boolean;
50
+ static defaultTransformer: typeof ShazamTransformer;
51
+ static newestFirst: boolean;
52
+ static schema: any;
53
+ private lookup?;
54
+ setup(): Promise<void>;
55
+ /** The recognition ID: the same ID the Shazam app gives the Shazam on every device. */
56
+ keyOf(record: Record): string | null;
57
+ occurredAt(record: Record): Date | undefined;
58
+ private query;
59
+ extract(): AsyncGenerator<Record>;
60
+ private recordsOf;
61
+ private shazamOf;
62
+ private decode;
63
+ private parseSong;
64
+ determineCount(): Promise<number | null>;
65
+ }
66
+ //# sourceMappingURL=ShazamExtractor.d.ts.map
@@ -0,0 +1,183 @@
1
+ import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, getRow, iterateRows, timeRangeConditions, unixToSafariTimestamp, } from '@chronicle.app/etl-sqlite';
2
+ import { getICloudAccount } from '@chronicle.app/icloud';
3
+ import { z } from 'zod';
4
+ import { AppleMusicLookup, LOOKUP_BATCH, countryOf } from '../appleMusic.js';
5
+ import { unarchive } from '../archive.js';
6
+ import ShazamTransformer from './ShazamTransformer.js';
7
+ const DEFAULT_DB = `${process.env.HOME}/Library/Application Support/com.apple.shazamd/ShazamLibrary.sqlite`;
8
+ // since/until bounds land on ZDATE, which is seconds since the Apple epoch.
9
+ const appleBound = (date) => unixToSafariTimestamp(date.getTime() / 1000);
10
+ /**
11
+ * The Shazam library that macOS keeps for Music Recognition (`shazamd`). It
12
+ * mirrors the iCloud library the Shazam app syncs to, so it holds the songs
13
+ * recognized on every device signed in to the account.
14
+ */
15
+ export class ShazamExtractor extends SqliteExtractor {
16
+ static source = 'shazam';
17
+ static description = 'Songs you identified with Shazam';
18
+ static delivery = 'local';
19
+ static strategy = 'app-db';
20
+ static recordTypes = ['listens'];
21
+ static default = true;
22
+ static defaultTransformer = ShazamTransformer;
23
+ static newestFirst = true;
24
+ static schema = SqliteExtractor.schema.omit({ input: true }).extend({
25
+ input: z.string().describe('Path to ShazamLibrary.sqlite').default(DEFAULT_DB),
26
+ account: z
27
+ .object({
28
+ accountID: z.string(),
29
+ email: z.string(),
30
+ displayName: z.string(),
31
+ dsid: z.string().optional(),
32
+ })
33
+ .nullable()
34
+ .optional(),
35
+ linkArtists: z
36
+ .boolean()
37
+ .optional()
38
+ .describe("Look up each song's main artist on Apple Music, to link artists by their Apple Music ID"),
39
+ });
40
+ lookup;
41
+ async setup() {
42
+ await super.setup();
43
+ if (!this.config.linkArtists)
44
+ return;
45
+ this.lookup = new AppleMusicLookup({
46
+ info: message => this.logger.info(message),
47
+ failure: error => {
48
+ this.logger.warn(`Can't look up artists on Apple Music: ${error.message}`);
49
+ this.hint("Can't reach Apple Music, so some artists aren't linked", {
50
+ action: 'Check your connection and run `chronicle extract shazam --link-artists` again.',
51
+ });
52
+ },
53
+ });
54
+ }
55
+ /** The recognition ID: the same ID the Shazam app gives the Shazam on every device. */
56
+ keyOf(record) {
57
+ return record.data.recognitionID ?? null;
58
+ }
59
+ occurredAt(record) {
60
+ return new Date(record.data.date);
61
+ }
62
+ // A sync can write the same Shazam twice, so take one row per recognition
63
+ // ID: the most recently modified.
64
+ query(select) {
65
+ const range = timeRangeConditions('ZDATE', this.config, { convert: appleBound });
66
+ const where = ['copy = 1', ...range.conditions].join(' AND ');
67
+ const sql = `
68
+ WITH shazams AS (
69
+ SELECT *, COALESCE(ZRECOGNITIONID, ZSYNCID) AS id,
70
+ ROW_NUMBER() OVER (
71
+ PARTITION BY COALESCE(ZRECOGNITIONID, ZSYNCID)
72
+ ORDER BY ZMODIFIEDDATE DESC, Z_PK DESC
73
+ ) AS copy
74
+ FROM ZSHTRACKMO
75
+ WHERE COALESCE(ZRECOGNITIONID, ZSYNCID) IS NOT NULL AND ZDATE IS NOT NULL
76
+ )
77
+ SELECT ${select} FROM shazams WHERE ${where}`;
78
+ return { sql, values: range.values };
79
+ }
80
+ async *extract() {
81
+ if (!this.db)
82
+ throw new Error('Database not initialized');
83
+ const { sql, values } = this.query(`
84
+ id, ZSYNCID AS syncID, ZDATE AS date, ZTITLE AS title, ZSUBTITLE AS subtitle,
85
+ ZSHAZAMKEY AS shazamKey, ZSHAZAMURL AS shazamURL, ZPROVIDERNAME AS providerName,
86
+ ZLATITUDE AS latitude, ZLONGITUDE AS longitude, ZARTWORKURL AS artworkURL,
87
+ ZAPPLEMUSICID AS appleMusicID, ZAPPLEMUSICURL AS appleMusicURL, ZISRC AS isrc,
88
+ ZALBUMNAME AS albumName, ZRELEASEDATE AS releaseDate, ZEXPLICIT AS explicit,
89
+ ZGENRES AS genres, ZRAWSONGRESPONSE AS rawSongResponse`);
90
+ let ordered = `${sql} ORDER BY ZDATE DESC`;
91
+ const limit = this.getEffectiveLimit();
92
+ if (limit !== null) {
93
+ ordered += ' LIMIT ?';
94
+ values.push(limit);
95
+ }
96
+ const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
97
+ // With linkArtists, Shazams go out a batch at a time, after their lookups.
98
+ let batch = [];
99
+ for (const row of iterateRows(this.db.prepare(ordered), ...values)) {
100
+ batch.push(this.shazamOf(row));
101
+ if (batch.length < (this.lookup ? LOOKUP_BATCH : 1))
102
+ continue;
103
+ yield* await this.recordsOf(batch, account);
104
+ batch = [];
105
+ }
106
+ yield* await this.recordsOf(batch, account);
107
+ }
108
+ async recordsOf(shazams, account) {
109
+ if (this.lookup) {
110
+ const songs = shazams
111
+ .filter(shazam => shazam.appleMusicID)
112
+ .map(shazam => ({ id: shazam.appleMusicID, country: countryOf(shazam.appleMusicURL) }));
113
+ const artists = await this.lookup.artistsOf(songs);
114
+ for (const shazam of shazams) {
115
+ const artist = artists.get(shazam.appleMusicID ?? '');
116
+ if (artist)
117
+ shazam.appleMusicArtist = artist;
118
+ }
119
+ }
120
+ return shazams.map(shazam => this.createRecord(shazam, { account }));
121
+ }
122
+ shazamOf(row) {
123
+ const genres = this.decode(row.genres, 'genres');
124
+ const response = this.decode(row.rawSongResponse, 'rawSongResponse');
125
+ const songData = response?.SHMediaLibraryDataStoreRawResponseSongsData;
126
+ return {
127
+ recognitionID: row.id,
128
+ syncID: row.syncID ?? null,
129
+ date: appleDate(row.date),
130
+ title: row.title ?? null,
131
+ subtitle: row.subtitle ?? null,
132
+ shazamKey: row.shazamKey ?? null,
133
+ shazamURL: row.shazamURL ?? null,
134
+ providerName: row.providerName ?? null,
135
+ latitude: row.latitude ?? null,
136
+ longitude: row.longitude ?? null,
137
+ artworkURL: row.artworkURL ?? null,
138
+ appleMusicID: row.appleMusicID ?? null,
139
+ appleMusicURL: row.appleMusicURL ?? null,
140
+ isrc: row.isrc ?? null,
141
+ albumName: row.albumName ?? null,
142
+ releaseDate: row.releaseDate === null ? null : appleDate(row.releaseDate).slice(0, 10),
143
+ explicit: row.explicit === null ? null : row.explicit === 1,
144
+ genres: Array.isArray(genres) ? genres.filter(g => typeof g === 'string') : [],
145
+ song: songData instanceof Uint8Array ? this.parseSong(songData) : null,
146
+ };
147
+ }
148
+ // A blob that doesn't decode loses only that field, not the Shazam.
149
+ decode(blob, field) {
150
+ if (!blob)
151
+ return null;
152
+ try {
153
+ return unarchive(blob);
154
+ }
155
+ catch (error) {
156
+ this.logger.warn(`Can't read a Shazam's ${field}: ${error.message}`);
157
+ return null;
158
+ }
159
+ }
160
+ parseSong(data) {
161
+ try {
162
+ return JSON.parse(Buffer.from(data).toString('utf8'));
163
+ }
164
+ catch (error) {
165
+ this.logger.warn(`Can't read a Shazam's Apple Music song: ${error.message}`);
166
+ return null;
167
+ }
168
+ }
169
+ async determineCount() {
170
+ if (!this.db)
171
+ return null;
172
+ try {
173
+ const { sql, values } = this.query('COUNT(*) AS n');
174
+ return getRow(this.db.prepare(sql), ...values)?.n ?? null;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ }
181
+ function appleDate(seconds) {
182
+ return new Date((seconds + APPLE_EPOCH_OFFSET_SECONDS) * 1000).toISOString();
183
+ }
@@ -0,0 +1,14 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ /**
4
+ * ListenAction agent: you object: MusicRecording location?
5
+ * MusicRecording (Shazam's copy, by its Shazam key)
6
+ * artist: MusicGroup (Apple Music's, by its ID, or Shazam's artist line)
7
+ * inAlbum: MusicAlbum (Apple Music's, by its ID)
8
+ * sameAs: MusicRecording (ISRC), MusicRecording (Apple Music)
9
+ */
10
+ export default class ShazamTransformer extends ChronicleTransformer {
11
+ static source: string;
12
+ transform(record: Record): Promise<ActionAndChildren[]>;
13
+ }
14
+ //# sourceMappingURL=ShazamTransformer.d.ts.map
@@ -0,0 +1,163 @@
1
+ import { ChronicleTransformer, ISRC_SOURCE, createImageObject, normalizeIsrc, } from '@chronicle.app/etl';
2
+ import { buildICloudPersonSchema } from '@chronicle.app/icloud';
3
+ const SOURCE = 'shazam';
4
+ /** Apple Music's catalog: the namespace of its song, album, and artist IDs. */
5
+ const APPLE_MUSIC = 'apple-music';
6
+ // Apple files every song under its root genre as well as its own.
7
+ const ROOT_GENRE = 'Music';
8
+ // The artwork Shazam shows for a song it has no cover for.
9
+ const NO_ARTWORK = /^https:\/\/images\.shazam\.com\/static\/coverart\/unavailable/;
10
+ /**
11
+ * ListenAction agent: you object: MusicRecording location?
12
+ * MusicRecording (Shazam's copy, by its Shazam key)
13
+ * artist: MusicGroup (Apple Music's, by its ID, or Shazam's artist line)
14
+ * inAlbum: MusicAlbum (Apple Music's, by its ID)
15
+ * sameAs: MusicRecording (ISRC), MusicRecording (Apple Music)
16
+ */
17
+ export default class ShazamTransformer extends ChronicleTransformer {
18
+ static source = SOURCE;
19
+ async transform(record) {
20
+ if (record.extraction.recordType !== 'listens')
21
+ return [];
22
+ const shazam = record.data;
23
+ // Without an iCloud account the listener can't be identified, so omit them.
24
+ const listener = await buildICloudPersonSchema(record.context.account);
25
+ const recording = buildRecording(shazam);
26
+ const location = locationOf(shazam);
27
+ const action = {
28
+ '@type': 'ListenAction',
29
+ '@key': ['@type', 'source', 'sourceId'],
30
+ source: SOURCE,
31
+ sourceId: shazam.recognitionID,
32
+ timestamp: new Date(shazam.date),
33
+ ...(listener && { agent: listener }),
34
+ ...(recording && { object: recording }),
35
+ ...(location && { location }),
36
+ };
37
+ return [action];
38
+ }
39
+ }
40
+ /** Shazam's copy of the song, keyed by its Shazam key and named as Shazam names it. */
41
+ function buildRecording(shazam) {
42
+ if (!shazam.shazamKey)
43
+ return null;
44
+ const attributes = shazam.song?.attributes ?? {};
45
+ const { durationInMillis } = attributes;
46
+ const genres = shazam.genres.filter(genre => genre !== ROOT_GENRE);
47
+ const album = buildAlbum(shazam);
48
+ const cover = coverOf(shazam.artworkURL);
49
+ const artist = buildArtist(shazam);
50
+ const sameAs = [];
51
+ const isrc = shazam.isrc && normalizeIsrc(shazam.isrc);
52
+ if (isrc)
53
+ sameAs.push(reference(ISRC_SOURCE, isrc));
54
+ if (shazam.appleMusicID) {
55
+ sameAs.push({
56
+ ...reference(APPLE_MUSIC, shazam.appleMusicID),
57
+ ...(shazam.appleMusicURL && { url: withoutTracking(shazam.appleMusicURL) }),
58
+ });
59
+ }
60
+ return {
61
+ '@type': 'MusicRecording',
62
+ '@key': ['@type', 'source', 'sourceId'],
63
+ source: SOURCE,
64
+ sourceId: shazam.shazamKey,
65
+ ...(shazam.title && { name: shazam.title }),
66
+ ...(artist && { artist: [artist] }),
67
+ ...(album && { inAlbum: album }),
68
+ ...(typeof durationInMillis === 'number' && {
69
+ duration: `PT${Math.floor(durationInMillis / 1000)}S`,
70
+ }),
71
+ ...(genres.length > 0 && { genre: genres }),
72
+ ...(shazam.releaseDate && { datePublished: shazam.releaseDate }),
73
+ ...(cover && { emblem: cover }),
74
+ ...(sameAs.length > 0 && { sameAs }),
75
+ };
76
+ }
77
+ /**
78
+ * The song's main artist on Apple Music, by its ID, when `linkArtists` looked
79
+ * it up. Otherwise the artist line as Shazam words it, such as `Artist Feat.
80
+ * Guest`, keyed by that name, since Shazam gives no artist ID.
81
+ */
82
+ function buildArtist(shazam) {
83
+ const linked = shazam.appleMusicArtist;
84
+ if (linked) {
85
+ return {
86
+ '@type': 'MusicGroup',
87
+ '@key': ['@type', 'source', 'sourceId'],
88
+ source: APPLE_MUSIC,
89
+ sourceId: linked.id,
90
+ name: linked.name,
91
+ ...(linked.url && { url: linked.url }),
92
+ };
93
+ }
94
+ if (!shazam.subtitle)
95
+ return null;
96
+ return {
97
+ '@type': 'MusicGroup',
98
+ '@key': ['@type', 'source', 'name'],
99
+ source: SOURCE,
100
+ name: shazam.subtitle,
101
+ };
102
+ }
103
+ /** The album is Apple Music's, keyed by the album ID in the song's Apple Music link. */
104
+ function buildAlbum(shazam) {
105
+ const link = shazam.appleMusicURL ?? shazam.song?.attributes?.url;
106
+ const albumID = link?.match(/\/album\/(?:[^/?]+\/)?(\d+)/)?.[1];
107
+ if (!shazam.albumName || !albumID)
108
+ return null;
109
+ return {
110
+ '@type': 'MusicAlbum',
111
+ '@key': ['@type', 'source', 'sourceId'],
112
+ source: APPLE_MUSIC,
113
+ sourceId: albumID,
114
+ name: shazam.albumName,
115
+ };
116
+ }
117
+ function reference(source, sourceId) {
118
+ return {
119
+ '@type': 'MusicRecording',
120
+ '@key': ['@type', 'source', 'sourceId'],
121
+ source,
122
+ sourceId,
123
+ };
124
+ }
125
+ /**
126
+ * Where the song was recognized. A device without a fix records 0, 0 or
127
+ * -180, -180, which is no place on Earth.
128
+ */
129
+ function locationOf({ latitude, longitude }) {
130
+ if (latitude === null || longitude === null)
131
+ return null;
132
+ if (latitude === 0 && longitude === 0)
133
+ return null;
134
+ if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180)
135
+ return null;
136
+ return { '@type': 'Location', latitude, longitude };
137
+ }
138
+ /**
139
+ * The cover. Apple Music covers are stored as HEIC, which most browsers can't
140
+ * show, and Apple's image server gives the same size as a JPEG.
141
+ */
142
+ function coverOf(link) {
143
+ if (!link || NO_ARTWORK.test(link))
144
+ return null;
145
+ const url = link.replace(/^(https:\/\/[^/]+\.mzstatic\.com\/.*bb)\.heic$/, '$1.jpg');
146
+ const size = url.match(/\/(\d+)x(\d+)bb\.\w+$/);
147
+ return createImageObject({
148
+ url,
149
+ ...(size && { width: Number(size[1]), height: Number(size[2]) }),
150
+ });
151
+ }
152
+ /** An Apple Music link without its campaign parameters (`itscg`, `itsct`). */
153
+ function withoutTracking(link) {
154
+ try {
155
+ const url = new URL(link);
156
+ const kept = [...url.searchParams].filter(([name]) => !name.startsWith('its'));
157
+ url.search = new URLSearchParams(kept).toString();
158
+ return url.toString();
159
+ }
160
+ catch {
161
+ return link;
162
+ }
163
+ }
@@ -0,0 +1,3 @@
1
+ export { ShazamExtractor } from './connectors/ShazamExtractor.js';
2
+ export { default as ShazamTransformer } from './connectors/ShazamTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Export the extractor and transformer for plugin scanning
2
+ export { ShazamExtractor } from './connectors/ShazamExtractor.js';
3
+ export { default as ShazamTransformer } from './connectors/ShazamTransformer.js';
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "@chronicle.app/shazam",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js",
15
+ "shapes": "node src/shapes.test.js --update"
16
+ },
17
+ "keywords": [
18
+ "chronicle",
19
+ "shazam",
20
+ "music"
21
+ ],
22
+ "author": "Andrew Louis",
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "description": "Chronicle plugin for the songs you identified with Shazam, from the library macOS keeps for Music Recognition",
26
+ "devDependencies": {
27
+ "@chronicle.app/etl": "0.4.0",
28
+ "@chronicle.app/schema": "0.4.0",
29
+ "@chronicle.app/tsconfig": "0.4.0",
30
+ "@types/node": "^22.10.1",
31
+ "typescript": "^5.7.2"
32
+ },
33
+ "homepage": "https://chronicle.app",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/chronicle-app/chronicle.git",
37
+ "directory": "plugins/shazam"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org/"
42
+ },
43
+ "dependencies": {
44
+ "@chronicle.app/etl-sqlite": "0.4.0",
45
+ "@chronicle.app/icloud": "0.4.0",
46
+ "zod": "^3.24.1"
47
+ },
48
+ "peerDependencies": {
49
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
50
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=22.13.0"
54
+ },
55
+ "exports": {
56
+ ".": {
57
+ "types": "./dist/index.d.ts",
58
+ "import": "./dist/index.js"
59
+ }
60
+ },
61
+ "chronicle": {
62
+ "plugin": true,
63
+ "sources": {
64
+ "shazam": {
65
+ "strategies": {
66
+ "app-db": {
67
+ "delivery": "local",
68
+ "recordTypes": [
69
+ "listens"
70
+ ],
71
+ "default": true
72
+ }
73
+ },
74
+ "platforms": [
75
+ "darwin"
76
+ ]
77
+ }
78
+ },
79
+ "deepLinks": {
80
+ "shazam": {
81
+ "MusicRecording": "https://www.shazam.com/track/{sourceId}"
82
+ }
83
+ }
84
+ }
85
+ }