@chronicle.app/shazam 0.0.0-stage → 0.5.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 +21 -0
- package/README.md +48 -2
- package/dist/appleMusic.d.ts +30 -0
- package/dist/appleMusic.js +115 -0
- package/dist/archive.d.ts +3 -0
- package/dist/archive.js +52 -0
- package/dist/connectors/ShazamExtractor.d.ts +76 -0
- package/dist/connectors/ShazamExtractor.js +239 -0
- package/dist/connectors/ShazamTransformer.d.ts +14 -0
- package/dist/connectors/ShazamTransformer.js +163 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +117 -4
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
|
-
#
|
|
1
|
+
# @chronicle.app/shazam
|
|
2
2
|
|
|
3
|
-
|
|
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/apple`. 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
|
+
}
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { parseBinaryPlist, Uid } from '@chronicle.app/apple';
|
|
2
|
+
import { APPLE_EPOCH_OFFSET_SECONDS } from '@chronicle.app/etl-sqlite';
|
|
3
|
+
/**
|
|
4
|
+
* Core Data keeps a transformable attribute, such as a track's genres, as an
|
|
5
|
+
* NSKeyedArchiver archive in a binary property list (`bplist00`). This reads
|
|
6
|
+
* the archives shazamd writes: arrays, dictionaries, strings, numbers, dates,
|
|
7
|
+
* and data. Other classes come back as `null`.
|
|
8
|
+
*/
|
|
9
|
+
// Nested containers deeper than this are a malformed (or cyclic) file.
|
|
10
|
+
const MAX_DEPTH = 64;
|
|
11
|
+
/** Decode an NSKeyedArchiver archive to plain values. */
|
|
12
|
+
export function unarchive(bytes) {
|
|
13
|
+
const archive = parseBinaryPlist(bytes);
|
|
14
|
+
const objects = archive?.$objects;
|
|
15
|
+
if (archive?.$archiver !== 'NSKeyedArchiver' || !Array.isArray(objects)) {
|
|
16
|
+
throw new Error('Not a keyed archive');
|
|
17
|
+
}
|
|
18
|
+
const resolve = (value, depth) => {
|
|
19
|
+
if (depth > MAX_DEPTH)
|
|
20
|
+
throw new Error('Malformed keyed archive');
|
|
21
|
+
if (value instanceof Uid) {
|
|
22
|
+
// Entry 0 is `$null`, the archive's nil.
|
|
23
|
+
if (value.index === 0)
|
|
24
|
+
return null;
|
|
25
|
+
return resolve(objects[value.index], depth + 1);
|
|
26
|
+
}
|
|
27
|
+
if (value === null || typeof value !== 'object' || value instanceof Date)
|
|
28
|
+
return value;
|
|
29
|
+
if (value instanceof Uint8Array)
|
|
30
|
+
return value;
|
|
31
|
+
if (Array.isArray(value))
|
|
32
|
+
return value.map(item => resolve(item, depth + 1));
|
|
33
|
+
if (Array.isArray(value['NS.keys']) && Array.isArray(value['NS.objects'])) {
|
|
34
|
+
const values = value['NS.objects'];
|
|
35
|
+
return Object.fromEntries(value['NS.keys'].map((key, i) => [
|
|
36
|
+
String(resolve(key, depth + 1)),
|
|
37
|
+
resolve(values[i], depth + 1),
|
|
38
|
+
]));
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(value['NS.objects']))
|
|
41
|
+
return resolve(value['NS.objects'], depth + 1);
|
|
42
|
+
if ('NS.string' in value)
|
|
43
|
+
return resolve(value['NS.string'], depth + 1);
|
|
44
|
+
if ('NS.data' in value)
|
|
45
|
+
return resolve(value['NS.data'], depth + 1);
|
|
46
|
+
if (typeof value['NS.time'] === 'number') {
|
|
47
|
+
return new Date((value['NS.time'] + APPLE_EPOCH_OFFSET_SECONDS) * 1000);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
return resolve(archive.$top?.root ?? null, 0);
|
|
52
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Record, type Survey, type SurveyOptions } 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 databases: {
|
|
53
|
+
tables: {
|
|
54
|
+
ZSHTRACKMO: string[];
|
|
55
|
+
};
|
|
56
|
+
}[];
|
|
57
|
+
static schema: any;
|
|
58
|
+
private lookup?;
|
|
59
|
+
setup(): Promise<void>;
|
|
60
|
+
/** The recognition ID: the same ID the Shazam app gives the Shazam on every device. */
|
|
61
|
+
keyOf(record: Record): string | null;
|
|
62
|
+
occurredAt(record: Record): Date | undefined;
|
|
63
|
+
private query;
|
|
64
|
+
extract(): AsyncGenerator<Record>;
|
|
65
|
+
private recordsOf;
|
|
66
|
+
private shazamOf;
|
|
67
|
+
private decode;
|
|
68
|
+
private parseSong;
|
|
69
|
+
determineCount(): Promise<number | null>;
|
|
70
|
+
/**
|
|
71
|
+
* The Shazams a run reads and when, of how many songs, by how many artists:
|
|
72
|
+
* by Shazam's artist line, as a run without linkArtists keys them.
|
|
73
|
+
*/
|
|
74
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=ShazamExtractor.d.ts.map
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, allRows, countsByMonth, getRow, iterateRows, monthSql, timeRangeConditions, unixToSafariTimestamp, } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import { getICloudAccount } from '@chronicle.app/apple';
|
|
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
|
+
// What extract() and survey() read, which a ShazamLibrary.sqlite must have.
|
|
25
|
+
static databases = [
|
|
26
|
+
{
|
|
27
|
+
tables: {
|
|
28
|
+
ZSHTRACKMO: [
|
|
29
|
+
'Z_PK',
|
|
30
|
+
'ZRECOGNITIONID',
|
|
31
|
+
'ZSYNCID',
|
|
32
|
+
'ZDATE',
|
|
33
|
+
'ZMODIFIEDDATE',
|
|
34
|
+
'ZTITLE',
|
|
35
|
+
'ZSUBTITLE',
|
|
36
|
+
'ZSHAZAMKEY',
|
|
37
|
+
'ZSHAZAMURL',
|
|
38
|
+
'ZPROVIDERNAME',
|
|
39
|
+
'ZLATITUDE',
|
|
40
|
+
'ZLONGITUDE',
|
|
41
|
+
'ZARTWORKURL',
|
|
42
|
+
'ZAPPLEMUSICID',
|
|
43
|
+
'ZAPPLEMUSICURL',
|
|
44
|
+
'ZISRC',
|
|
45
|
+
'ZALBUMNAME',
|
|
46
|
+
'ZRELEASEDATE',
|
|
47
|
+
'ZEXPLICIT',
|
|
48
|
+
'ZGENRES',
|
|
49
|
+
'ZRAWSONGRESPONSE',
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
55
|
+
input: z.string().describe('Path to ShazamLibrary.sqlite').default(DEFAULT_DB),
|
|
56
|
+
account: z
|
|
57
|
+
.object({
|
|
58
|
+
accountID: z.string(),
|
|
59
|
+
email: z.string(),
|
|
60
|
+
displayName: z.string(),
|
|
61
|
+
dsid: z.string().optional(),
|
|
62
|
+
})
|
|
63
|
+
.nullable()
|
|
64
|
+
.optional(),
|
|
65
|
+
linkArtists: z
|
|
66
|
+
.boolean()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe("Look up each song's main artist on Apple Music, to link artists by their Apple Music ID"),
|
|
69
|
+
});
|
|
70
|
+
lookup;
|
|
71
|
+
async setup() {
|
|
72
|
+
await super.setup();
|
|
73
|
+
if (!this.config.linkArtists)
|
|
74
|
+
return;
|
|
75
|
+
this.lookup = new AppleMusicLookup({
|
|
76
|
+
info: message => this.logger.info(message),
|
|
77
|
+
failure: error => {
|
|
78
|
+
this.logger.warn(`Can't look up artists on Apple Music: ${error.message}`);
|
|
79
|
+
this.hint("Can't reach Apple Music, so some artists aren't linked", {
|
|
80
|
+
action: 'Check your connection and run `chronicle extract shazam --link-artists` again.',
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** The recognition ID: the same ID the Shazam app gives the Shazam on every device. */
|
|
86
|
+
keyOf(record) {
|
|
87
|
+
return record.data.recognitionID ?? null;
|
|
88
|
+
}
|
|
89
|
+
occurredAt(record) {
|
|
90
|
+
return new Date(record.data.date);
|
|
91
|
+
}
|
|
92
|
+
// A sync can write the same Shazam twice, so take one row per recognition
|
|
93
|
+
// ID: the most recently modified.
|
|
94
|
+
query(select) {
|
|
95
|
+
const range = timeRangeConditions('ZDATE', this.config, { convert: appleBound });
|
|
96
|
+
const where = ['copy = 1', ...range.conditions].join(' AND ');
|
|
97
|
+
const sql = `
|
|
98
|
+
WITH shazams AS (
|
|
99
|
+
SELECT *, COALESCE(ZRECOGNITIONID, ZSYNCID) AS id,
|
|
100
|
+
ROW_NUMBER() OVER (
|
|
101
|
+
PARTITION BY COALESCE(ZRECOGNITIONID, ZSYNCID)
|
|
102
|
+
ORDER BY ZMODIFIEDDATE DESC, Z_PK DESC
|
|
103
|
+
) AS copy
|
|
104
|
+
FROM ZSHTRACKMO
|
|
105
|
+
WHERE COALESCE(ZRECOGNITIONID, ZSYNCID) IS NOT NULL AND ZDATE IS NOT NULL
|
|
106
|
+
)
|
|
107
|
+
SELECT ${select} FROM shazams WHERE ${where}`;
|
|
108
|
+
return { sql, values: range.values };
|
|
109
|
+
}
|
|
110
|
+
async *extract() {
|
|
111
|
+
if (!this.db)
|
|
112
|
+
throw new Error('Database not initialized');
|
|
113
|
+
const { sql, values } = this.query(`
|
|
114
|
+
id, ZSYNCID AS syncID, ZDATE AS date, ZTITLE AS title, ZSUBTITLE AS subtitle,
|
|
115
|
+
ZSHAZAMKEY AS shazamKey, ZSHAZAMURL AS shazamURL, ZPROVIDERNAME AS providerName,
|
|
116
|
+
ZLATITUDE AS latitude, ZLONGITUDE AS longitude, ZARTWORKURL AS artworkURL,
|
|
117
|
+
ZAPPLEMUSICID AS appleMusicID, ZAPPLEMUSICURL AS appleMusicURL, ZISRC AS isrc,
|
|
118
|
+
ZALBUMNAME AS albumName, ZRELEASEDATE AS releaseDate, ZEXPLICIT AS explicit,
|
|
119
|
+
ZGENRES AS genres, ZRAWSONGRESPONSE AS rawSongResponse`);
|
|
120
|
+
let ordered = `${sql} ORDER BY ZDATE DESC`;
|
|
121
|
+
const limit = this.getEffectiveLimit();
|
|
122
|
+
if (limit !== null) {
|
|
123
|
+
ordered += ' LIMIT ?';
|
|
124
|
+
values.push(limit);
|
|
125
|
+
}
|
|
126
|
+
const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
|
|
127
|
+
// With linkArtists, Shazams go out a batch at a time, after their lookups.
|
|
128
|
+
let batch = [];
|
|
129
|
+
for (const row of iterateRows(this.db.prepare(ordered), ...values)) {
|
|
130
|
+
batch.push(this.shazamOf(row));
|
|
131
|
+
if (batch.length < (this.lookup ? LOOKUP_BATCH : 1))
|
|
132
|
+
continue;
|
|
133
|
+
yield* await this.recordsOf(batch, account);
|
|
134
|
+
batch = [];
|
|
135
|
+
}
|
|
136
|
+
yield* await this.recordsOf(batch, account);
|
|
137
|
+
}
|
|
138
|
+
async recordsOf(shazams, account) {
|
|
139
|
+
if (this.lookup) {
|
|
140
|
+
const songs = shazams
|
|
141
|
+
.filter(shazam => shazam.appleMusicID)
|
|
142
|
+
.map(shazam => ({ id: shazam.appleMusicID, country: countryOf(shazam.appleMusicURL) }));
|
|
143
|
+
const artists = await this.lookup.artistsOf(songs);
|
|
144
|
+
for (const shazam of shazams) {
|
|
145
|
+
const artist = artists.get(shazam.appleMusicID ?? '');
|
|
146
|
+
if (artist)
|
|
147
|
+
shazam.appleMusicArtist = artist;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return shazams.map(shazam => this.createRecord(shazam, { account }));
|
|
151
|
+
}
|
|
152
|
+
shazamOf(row) {
|
|
153
|
+
const genres = this.decode(row.genres, 'genres');
|
|
154
|
+
const response = this.decode(row.rawSongResponse, 'rawSongResponse');
|
|
155
|
+
const songData = response?.SHMediaLibraryDataStoreRawResponseSongsData;
|
|
156
|
+
return {
|
|
157
|
+
recognitionID: row.id,
|
|
158
|
+
syncID: row.syncID ?? null,
|
|
159
|
+
date: appleDate(row.date),
|
|
160
|
+
title: row.title ?? null,
|
|
161
|
+
subtitle: row.subtitle ?? null,
|
|
162
|
+
shazamKey: row.shazamKey ?? null,
|
|
163
|
+
shazamURL: row.shazamURL ?? null,
|
|
164
|
+
providerName: row.providerName ?? null,
|
|
165
|
+
latitude: row.latitude ?? null,
|
|
166
|
+
longitude: row.longitude ?? null,
|
|
167
|
+
artworkURL: row.artworkURL ?? null,
|
|
168
|
+
appleMusicID: row.appleMusicID ?? null,
|
|
169
|
+
appleMusicURL: row.appleMusicURL ?? null,
|
|
170
|
+
isrc: row.isrc ?? null,
|
|
171
|
+
albumName: row.albumName ?? null,
|
|
172
|
+
releaseDate: row.releaseDate === null ? null : appleDate(row.releaseDate).slice(0, 10),
|
|
173
|
+
explicit: row.explicit === null ? null : row.explicit === 1,
|
|
174
|
+
genres: Array.isArray(genres) ? genres.filter(g => typeof g === 'string') : [],
|
|
175
|
+
song: songData instanceof Uint8Array ? this.parseSong(songData) : null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// A blob that doesn't decode loses only that field, not the Shazam.
|
|
179
|
+
decode(blob, field) {
|
|
180
|
+
if (!blob)
|
|
181
|
+
return null;
|
|
182
|
+
try {
|
|
183
|
+
return unarchive(blob);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.logger.warn(`Can't read a Shazam's ${field}: ${error.message}`);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
parseSong(data) {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(Buffer.from(data).toString('utf8'));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
this.logger.warn(`Can't read a Shazam's Apple Music song: ${error.message}`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async determineCount() {
|
|
200
|
+
if (!this.db)
|
|
201
|
+
return null;
|
|
202
|
+
try {
|
|
203
|
+
const { sql, values } = this.query('COUNT(*) AS n');
|
|
204
|
+
return getRow(this.db.prepare(sql), ...values)?.n ?? null;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The Shazams a run reads and when, of how many songs, by how many artists:
|
|
212
|
+
* by Shazam's artist line, as a run without linkArtists keys them.
|
|
213
|
+
*/
|
|
214
|
+
async survey(options = {}) {
|
|
215
|
+
if (!this.db)
|
|
216
|
+
return null;
|
|
217
|
+
const { sql, values } = this.query(`COUNT(*) AS n, MIN(ZDATE) AS first, MAX(ZDATE) AS last,
|
|
218
|
+
COUNT(DISTINCT ZSHAZAMKEY) AS songs, COUNT(DISTINCT ZSUBTITLE) AS artists`);
|
|
219
|
+
const row = getRow(this.db.prepare(sql), ...values);
|
|
220
|
+
const months = this.query(`${monthSql(`ZDATE + ${APPLE_EPOCH_OFFSET_SECONDS}`)} AS month, COUNT(*) AS n`);
|
|
221
|
+
const byMonth = options.byMonth &&
|
|
222
|
+
countsByMonth(allRows(this.db.prepare(`${months.sql} GROUP BY month`), ...months.values));
|
|
223
|
+
return {
|
|
224
|
+
listens: {
|
|
225
|
+
count: row.n,
|
|
226
|
+
...(row.first !== null && { earliest: new Date(appleDate(row.first)) }),
|
|
227
|
+
...(row.last !== null && { latest: new Date(appleDate(row.last)) }),
|
|
228
|
+
...(byMonth && { byMonth }),
|
|
229
|
+
measures: [
|
|
230
|
+
{ key: 'songs', label: 'songs', value: row.songs },
|
|
231
|
+
{ key: 'artists', label: 'artists', value: row.artists },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function appleDate(seconds) {
|
|
238
|
+
return new Date((seconds + APPLE_EPOCH_OFFSET_SECONDS) * 1000).toISOString();
|
|
239
|
+
}
|
|
@@ -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/apple';
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,119 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/shazam",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.5.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.5.0",
|
|
28
|
+
"@chronicle.app/schema": "0.5.0",
|
|
29
|
+
"@chronicle.app/tsconfig": "0.5.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.5.0",
|
|
45
|
+
"@chronicle.app/apple": "0.5.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
|
+
"locations": [
|
|
73
|
+
{
|
|
74
|
+
"path": "~/Library/Application Support/com.apple.shazamd/ShazamLibrary.sqlite"
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
"databases": [
|
|
78
|
+
{
|
|
79
|
+
"tables": {
|
|
80
|
+
"ZSHTRACKMO": [
|
|
81
|
+
"Z_PK",
|
|
82
|
+
"ZRECOGNITIONID",
|
|
83
|
+
"ZSYNCID",
|
|
84
|
+
"ZDATE",
|
|
85
|
+
"ZMODIFIEDDATE",
|
|
86
|
+
"ZTITLE",
|
|
87
|
+
"ZSUBTITLE",
|
|
88
|
+
"ZSHAZAMKEY",
|
|
89
|
+
"ZSHAZAMURL",
|
|
90
|
+
"ZPROVIDERNAME",
|
|
91
|
+
"ZLATITUDE",
|
|
92
|
+
"ZLONGITUDE",
|
|
93
|
+
"ZARTWORKURL",
|
|
94
|
+
"ZAPPLEMUSICID",
|
|
95
|
+
"ZAPPLEMUSICURL",
|
|
96
|
+
"ZISRC",
|
|
97
|
+
"ZALBUMNAME",
|
|
98
|
+
"ZRELEASEDATE",
|
|
99
|
+
"ZEXPLICIT",
|
|
100
|
+
"ZGENRES",
|
|
101
|
+
"ZRAWSONGRESPONSE"
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"platforms": [
|
|
109
|
+
"darwin"
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"deepLinks": {
|
|
114
|
+
"shazam": {
|
|
115
|
+
"MusicRecording": "https://www.shazam.com/track/{sourceId}"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|