A tool to scrobble tracks from your Apple Music data export to Teal.fm.

init

+6
.env.example
···
+
PDS="https://bsky.social"
+
HANDLE=
+
APP_PASSWORD=
+
+
# This is required to use the Musicbrainz API ethically
+
MUSICBRAINZ_CONTACT_EMAIL=
+42
.gitignore
···
+
# dependencies (bun install)
+
node_modules
+
+
# output
+
out
+
dist
+
*.tgz
+
+
# code coverage
+
coverage
+
*.lcov
+
+
# logs
+
logs
+
_.log
+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+
# dotenv environment variable files
+
.env
+
.env.development.local
+
.env.test.local
+
.env.production.local
+
.env.local
+
+
# caches
+
.eslintcache
+
.cache
+
*.tsbuildinfo
+
+
# IntelliJ based IDEs
+
.idea
+
+
# Finder (MacOS) folder config
+
.DS_Store
+
+
.env
+
+
node_modules
+
+
cache
+
+
artist_overrides.json
+15
README.md
···
+
# Apple Music Data Export -> Teal.fm
+
+
A tool to scrobble tracks from your Apple Music data export to Teal.fm with records in your ATProto repository.
+
+
To install dependencies:
+
+
```bash
+
bun install
+
```
+
+
To run:
+
+
```bash
+
bun run index.ts
+
```
+156
bun.lock
···
+
{
+
"lockfileVersion": 1,
+
"workspaces": {
+
"": {
+
"name": "tealfm-apple-music-import",
+
"dependencies": {
+
"@atproto/api": "^0.17.4",
+
"consola": "^3.4.2",
+
"musicbrainz-api": "^0.25.1",
+
"papaparse": "^5.5.3",
+
"unstorage": "^1.17.2",
+
"zod": "^4.1.12",
+
},
+
"devDependencies": {
+
"@types/bun": "latest",
+
"@types/papaparse": "^5.3.16",
+
},
+
"peerDependencies": {
+
"typescript": "^5",
+
},
+
},
+
},
+
"packages": {
+
"@atproto/api": ["@atproto/api@0.17.4", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-MRa0WdxyDiGF7fVKd/2ldvonsHQjsaLUOGw/PHrZ7J01lqlw/jaXLS25FNNYzjPGmGpnIyDCIg4Uucd/OblI9w=="],
+
+
"@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg=="],
+
+
"@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
+
+
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
+
+
"@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="],
+
+
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
+
+
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
+
+
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
+
+
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
+
+
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
+
+
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
+
+
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
+
+
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
+
+
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
+
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
+
+
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
+
+
"@types/papaparse": ["@types/papaparse@5.3.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg=="],
+
+
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
+
+
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+
+
"await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="],
+
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
+
+
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
+
+
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
+
+
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
+
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
+
+
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
+
+
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
+
+
"h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="],
+
+
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
+
+
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
+
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
+
+
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
+
+
"jsontoxml": ["jsontoxml@1.0.1", "", {}, "sha512-dtKGq0K8EWQBRqcAaePSgKR4Hyjfsz/LkurHSV3Cxk4H+h2fWDeaN2jzABz+ZmOJylgXS7FGeWmbZ6jgYUMdJQ=="],
+
+
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"musicbrainz-api": ["musicbrainz-api@0.25.1", "", { "dependencies": { "debug": "^4.3.4", "http-status-codes": "^2.1.4", "json-stringify-safe": "^5.0.1", "jsontoxml": "^1.0.1", "rate-limit-threshold": "^0.2.0", "spark-md5": "^3.0.2", "tough-cookie": "^5.0.0", "uuid": "^11.0.3" } }, "sha512-QyfuPo+6h1DCDeXC88EewoU0pZNfrfQ7JKfYy3RqTAYUoqpdVfxoZk91wQNWfqvOq0DcdQspYbTY+RrhYEjH4A=="],
+
+
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
+
+
"node-mock-http": ["node-mock-http@1.0.3", "", {}, "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog=="],
+
+
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
+
+
"papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="],
+
+
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
+
+
"rate-limit-threshold": ["rate-limit-threshold@0.2.0", "", { "dependencies": { "@biomejs/biome": "^1.8.3" } }, "sha512-4vRdh7MTX3wJaZG2Xj4IF6Z5Epr9KzZsq/FpLYVuIcgdmXht4jJADDyuyUOiSwB/ZXdZKdtEeCaY+keXVsBWQQ=="],
+
+
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
+
"spark-md5": ["spark-md5@3.0.2", "", {}, "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="],
+
+
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
+
+
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
+
+
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
+
+
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
+
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
+
+
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
+
+
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
+
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+
"unstorage": ["unstorage@1.17.2", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.4", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.0", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-cKEsD6iBWJgOMJ6vW1ID/SYuqNf8oN4yqRk8OYqaVQ3nnkJXOT1PSpaMh2QfzLs78UN5kSNRD2c/mgjT8tX7+w=="],
+
+
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
+
+
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
+
+
"@atproto/api/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+
"@atproto/common-web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+
"@atproto/lexicon/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+
"@atproto/xrpc/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
}
+
}
+24
package.json
···
+
{
+
"name": "tealfm-apple-music-import",
+
"module": "index.ts",
+
"devDependencies": {
+
"@types/bun": "latest",
+
"@types/papaparse": "^5.3.16"
+
},
+
"peerDependencies": {
+
"typescript": "^5"
+
},
+
"scripts": {
+
"start": "bun run src/index.ts",
+
"dev": "bun run --watch src/index.ts"
+
},
+
"type": "module",
+
"dependencies": {
+
"@atproto/api": "^0.17.4",
+
"consola": "^3.4.2",
+
"musicbrainz-api": "^0.25.1",
+
"papaparse": "^5.5.3",
+
"unstorage": "^1.17.2",
+
"zod": "^4.1.12"
+
}
+
}
+11
src/env.ts
···
+
import { z } from "zod";
+
+
const envSchema = z.object({
+
PDS: z.string(),
+
HANDLE: z.string(),
+
APP_PASSWORD: z.string(),
+
MUSICBRAINZ_CONTACT_EMAIL: z.string(),
+
});
+
+
export type Env = z.infer<typeof envSchema>;
+
export const env = envSchema.parse(Bun.env);
+104
src/index.ts
···
+
import { getCache, updateCache } from "./utils/cache";
+
import {
+
constructRecord,
+
createRecord,
+
getLastTimestamp,
+
} from "./utils/atproto";
+
import {
+
buildTrackCache,
+
getExtraSongData,
+
isEligible,
+
parseCSV,
+
} from "./utils/data";
+
import consola from "consola";
+
import { lookupTrack } from "./utils/musicbrainz";
+
import type { AppleMusicPlaybackEvent } from "./types";
+
+
const logger = consola.withTag("AM -> TEAL.FM");
+
+
const dataExportPath = "/Users/bryceoliver/Desktop/Apple Music Activity/";
+
const playActivity = await parseCSV(
+
dataExportPath,
+
"Apple Music Play Activity",
+
) as AppleMusicPlaybackEvent[];
+
+
const dailyTrackHistory = await parseCSV(
+
dataExportPath,
+
"Apple Music - Play History Daily Tracks",
+
);
+
+
buildTrackCache(dailyTrackHistory);
+
+
const lastTimestamp = new Date(await getLastTimestamp());
+
logger.info("Last Teal.fm record timestamp: ", lastTimestamp.toDateString());
+
+
const progress = await getCache();
+
+
const eligible = playActivity.filter((event) =>
+
isEligible(event) && new Date(event["Event End Timestamp"]) > lastTimestamp
+
);
+
+
const notYetImported = eligible.filter((event) =>
+
!progress.some((p) =>
+
p.songName === event["Song Name"] &&
+
p.timestamp === event["Event End Timestamp"]
+
)
+
);
+
+
logger.info(`Importing ${notYetImported.length.toLocaleString()} tracks`);
+
+
const events = notYetImported
+
.sort(
+
(a, b) =>
+
new Date(a["Event End Timestamp"]).getTime() -
+
new Date(b["Event End Timestamp"]).getTime(),
+
);
+
+
const missingTrackData = [
+
...new Set(
+
events.filter((x) => getExtraSongData(x["Song Name"]) == null).map((
+
x,
+
) => x["Song Name"]),
+
),
+
];
+
+
if (missingTrackData.length > 0) {
+
logger.warn(
+
"Missing track data! Please create an artist_overrides.json file to specify the artist for the following tracks",
+
);
+
console.log(missingTrackData.join(", "));
+
process.exit(1);
+
}
+
+
for (const event of events) {
+
const details = getExtraSongData(event["Song Name"])!;
+
logger.log(`Resolving track ${event["Song Name"]} by ${details.artist}..`);
+
+
const track = await lookupTrack(
+
details.artist,
+
event["Album Name"],
+
event["Song Name"],
+
)!;
+
+
if (!track) {
+
logger.error("Failed to resolve track.");
+
process.exit(1);
+
}
+
+
logger.success(
+
`Resolved track to recording "${track.recording.title}" and release "${track.release.title}"!`,
+
);
+
+
const record = await constructRecord(
+
event["Event End Timestamp"],
+
event["Song Name"],
+
track.recording,
+
track.release,
+
details.originId,
+
);
+
+
console.log(record);
+
+
await updateCache(event);
+
await createRecord(record);
+
}
+210
src/types.ts
···
+
export type AppleMusicPlaybackEvent = {
+
"Age Bucket": string;
+
"Album Name": string;
+
"Apple ID Number": string;
+
"Apple Music Subscription": string;
+
"Auto Play": string;
+
"Build Version": string;
+
"Bundle Version": string;
+
"Camera Option": string;
+
"Carrier Name": string;
+
"Client Build Version": string;
+
"Client Device Name": string;
+
"Client IP Address": string;
+
"Client Platform": string;
+
"Container Album Name": string;
+
"Container Artist Name": string;
+
"Container Global Playlist ID": string;
+
"Container ID": string;
+
"Container iTunes Playlist ID": string;
+
"Container Library ID": string;
+
"Container Name": string;
+
"Container Origin Type": string;
+
"Container Personalized ID": string;
+
"Container Playlist Folder ID": string;
+
"Container Playlist ID": string;
+
"Container Radio Station ID": string;
+
"Container Radio Station Version": string;
+
"Container Season ID": string;
+
"Container Type": string;
+
"Contingency": string;
+
"Continuity Microphone Used": string;
+
"Device App Name": string;
+
"Device App Version": string;
+
"Device Identifier": string;
+
"Device OS Name": string;
+
"Device OS Version": string;
+
"Device Type": string;
+
"Display Count": string;
+
"Display Language": string;
+
"Display Type": string;
+
"End Position In Milliseconds": string;
+
"End Reason Type": string;
+
"Evaluation Variant": string;
+
"Event End Timestamp": string;
+
"Event ID": string;
+
"Event Post Date Time": string;
+
"Event Reason Hint Type": string;
+
"Event Received Timestamp": string;
+
"Event Start Timestamp": string;
+
"Event Timestamp": string;
+
"Event Type": string;
+
"Feature Name": string;
+
"Grace Period": string;
+
"Grouping": string;
+
"House ID": string;
+
"IP City": string;
+
"IP Country Code": string;
+
"IP Latitude": string;
+
"IP Longitude": string;
+
"IP Network": string;
+
"IP Network Type": string;
+
"IP Region Code": string;
+
"Is CMA Station": string;
+
"Is Collaborative": string;
+
"Is Delegated": string;
+
"Is Heatseeker Station": string;
+
"Is Heavy Rotation Station": string;
+
"Is Subscription Owner?": string;
+
"Is Vocal Attenuation": string;
+
"ISO Country": string;
+
"Item Type": string;
+
"Key Request": string;
+
"Lease Limit": string;
+
"Legacy Playback ID": string;
+
"Local Radio Station ID": string;
+
"Local Radio Station TuneIn ID": string;
+
"Managed ID": string;
+
"Matched Content": string;
+
"Media Bundle App Name": string;
+
"Media Bundle Type": string;
+
"Media Duration In Milliseconds": string;
+
"Media Type": string;
+
"Metrics Client ID": string;
+
"Milliseconds Since Play": string;
+
"Offline": string;
+
"Ownership Type": string;
+
"Personalized Name": string;
+
"Play Duration Milliseconds": string;
+
"Promotion Scenario ID": string;
+
"Pronunciation Displayed": string;
+
"Provided Audio Bit Depth": string;
+
"Provided Audio Channel": string;
+
"Provided Audio Sample Rate": string;
+
"Provided Bit Rate": string;
+
"Provided Codec": string;
+
"Provided Playback Format": string;
+
"Provider ID": string;
+
"Radio Format": string;
+
"Radio Seed ID": string;
+
"Radio Station Country": string;
+
"Radio Station ID": string;
+
"Radio Station Position": string;
+
"Radio Type": string;
+
"Radio User ID": string;
+
"Referral ID": string;
+
"Repeat Play": string;
+
"Report Type": string;
+
"Royalty Free": string;
+
"Sales Order Vendor ID": string;
+
"Session Is Shared": string;
+
"Shared Activity Devices-Current": string;
+
"Shared Activity Devices-Max": string;
+
"Shared Activity Type": string;
+
"Shelf Content Identifier": string;
+
"Shelf Content Position": string;
+
"Shelf Index": string;
+
"Shelf Type": string;
+
"Shelf Visible": string;
+
"Shuffle Play": string;
+
"Siri Request": string;
+
"Song Name": string;
+
"Source Model": string;
+
"Source Radio Name": string;
+
"Source Radio Type": string;
+
"Source Type": string;
+
"Start Position In Milliseconds": string;
+
"Store Front Name": string;
+
"Subscribed State": string;
+
"Subscription Bundle ID": string;
+
"Subscription Discovery Mode": string;
+
"Subscription Offer ID": string;
+
"Subscription Partner": string;
+
"Subscription Period": string;
+
"Subscription Pool Type": string;
+
"Subscription User ID": string;
+
"Transition Type": string;
+
"Translation Displayed": string;
+
"Use Listening History": string;
+
"User's Transition Type": string;
+
"User's Audio Quality": string;
+
"User's Playback Format": string;
+
"UTC Offset In Seconds": string;
+
"Vocal Attenuation Duration": string;
+
"Vocal Attenuation Model ID": string;
+
};
+
+
export type AppleMusicContainer = {
+
"Container Description": string;
+
"Container Type": string;
+
"Origin": string;
+
"Date Created": string;
+
"Play Duration Milliseconds": string;
+
"Artist Name": string;
+
"Last Played": string;
+
"Play Count": string;
+
"Genres": string;
+
"Artists": string;
+
};
+
+
export type AppleMusicTrackHistory = {
+
"Country": string;
+
"Track Identifier": string;
+
"Media type": string;
+
"Date Played": string;
+
"Hours": string;
+
"Play Duration Milliseconds": string;
+
"End Reason Type": string;
+
"Source Type": string;
+
"Play Count": string;
+
"Skip Count": string;
+
"Ignore For Recommendations": string;
+
"Track Reference": string;
+
"Track Description": string;
+
};
+
+
export type TealfmPlayRecord = {
+
isrc?: string;
+
$type: "fm.teal.alpha.feed.play";
+
//$type: "dev.indexx.teal.feed.play";
+
artists: Array<{
+
artistMbId: string;
+
artistName: string;
+
}>;
+
duration?: number;
+
originUrl?: string;
+
trackName: string;
+
playedTime: string;
+
releaseMbId: string;
+
releaseName: string;
+
recordingMbId: string;
+
submissionClientAgent: string;
+
musicServiceBaseDomain?: "music.apple.com";
+
};
+
+
export type XRPCError = {
+
error?: string;
+
message?: string;
+
};
+
+
export type ResolveHandleSuccess = {
+
did: string;
+
error?: never;
+
message?: never;
+
};
+
+
export type ResolveHandleFailure = XRPCError & {
+
did?: never;
+
};
+
+
export type ResolveHandleRes = ResolveHandleSuccess | ResolveHandleFailure;
+147
src/utils/atproto.ts
···
+
import type { IRecordingMatch, IRelease } from "musicbrainz-api";
+
import { env } from "../env";
+
import type { ResolveHandleRes, TealfmPlayRecord } from "../types";
+
import AtpAgent, { Agent, CredentialSession } from "@atproto/api";
+
import { getAppleMusicURL } from "./musicbrainz";
+
+
const agent = new AtpAgent({
+
service: env.PDS,
+
});
+
+
await agent.login({
+
identifier: env.HANDLE,
+
password: env.APP_PASSWORD,
+
});
+
+
async function _getDID(handle: string) {
+
const resolution: ResolveHandleRes = (await (await fetch(
+
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`,
+
)).json()) as ResolveHandleRes;
+
if (resolution.error) throw new Error("Failed to resolve handle to DID");
+
+
return resolution.did!;
+
}
+
+
async function _getPDSUrl(did: string) {
+
let services;
+
+
if (did.startsWith("did:plc:")) {
+
const resolution = (await (await fetch(
+
`https://plc.directory/${did}`,
+
)).json()) as any;
+
if (resolution.message) {
+
throw new Error("Failed to resolve resolve PDS URL");
+
}
+
+
services = resolution.service;
+
} else if (did.startsWith("did:web:")) {
+
const domain = did.replace("did:web:", "");
+
const resolution = (await (await fetch(
+
`${domain}/.well-known/did.json`,
+
)).json()) as any;
+
+
services = resolution.service;
+
}
+
+
return services.find((service: any) => service.id == "#atproto_pds")
+
.serviceEndpoint as string;
+
}
+
+
export async function getLastTimestamp() {
+
const did = await _getDID(env.HANDLE);
+
const pdsURL = await _getPDSUrl(did);
+
+
const listRecords = (await (await fetch(
+
`${pdsURL}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=fm.teal.alpha.feed.play&limit=1&reverse=false`,
+
)).json()) as {
+
records?: {
+
uri: string;
+
cid: string;
+
value: TealfmPlayRecord;
+
}[];
+
};
+
+
if (!listRecords.records || !listRecords.records[0]) {
+
throw new Error("Unable to find latest teal.fm play record");
+
}
+
+
const latest = listRecords.records[0].value;
+
if (!latest.playedTime) {
+
throw new Error("Missing timestamp from latest teal.fm play record");
+
}
+
+
return latest.playedTime;
+
}
+
+
export async function constructRecord(
+
timestamp: string,
+
trackName: string,
+
recording: IRecordingMatch,
+
release: IRelease,
+
originId: string | null,
+
) {
+
let originUrl: string | undefined =
+
`https://music.apple.com/us/song/${originId}`;
+
if (!originId) {
+
const remoteOriginUrl = await getAppleMusicURL(recording);
+
if (!remoteOriginUrl) {
+
originUrl = remoteOriginUrl;
+
} else {
+
originUrl = undefined;
+
}
+
}
+
+
const record: TealfmPlayRecord = {
+
isrc: (recording.isrcs || [])[0],
+
$type: "fm.teal.alpha.feed.play",
+
artists: recording["artist-credit"]?.map((credit) => ({
+
artistMbId: credit.artist.id,
+
artistName: credit.name,
+
})) ?? [],
+
duration: Math.floor(recording.length / 1000) || undefined,
+
originUrl,
+
trackName,
+
playedTime: timestamp,
+
releaseMbId: release.id,
+
releaseName: release.title,
+
recordingMbId: recording.id,
+
submissionClientAgent: "manual/unknown",
+
musicServiceBaseDomain: "music.apple.com",
+
};
+
+
return record;
+
}
+
+
export async function createRecord(record: TealfmPlayRecord) {
+
const maxRetries = 5;
+
let lastError: Error | undefined;
+
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
+
try {
+
const creation = await agent.com.atproto.repo.createRecord({
+
repo: agent.assertDid,
+
collection: "fm.teal.alpha.feed.play",
+
record,
+
});
+
return creation;
+
} catch (error) {
+
lastError = error instanceof Error
+
? error
+
: new Error(String(error));
+
console.error(
+
`Attempt ${attempt}/${maxRetries} failed:`,
+
lastError.message,
+
);
+
+
if (attempt < maxRetries) {
+
// Exponential backoff: 1s, 2s, 4s, 8s
+
const delayMs = Math.pow(2, attempt - 1) * 1000;
+
console.log(`Retrying in ${delayMs}ms...`);
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
+
}
+
}
+
}
+
+
console.error(`Failed to create record after ${maxRetries} attempts`);
+
process.exit(1);
+
}
+29
src/utils/cache.ts
···
+
import { readFile, writeFile } from "fs/promises";
+
import type { AppleMusicPlaybackEvent } from "../types";
+
+
const cacheFilePath = "cache/progress.json";
+
+
export type CacheRecord = {
+
songName: string;
+
timestamp: string;
+
};
+
+
export async function getCache(): Promise<CacheRecord[]> {
+
try {
+
const data = await readFile(cacheFilePath, "utf-8");
+
return JSON.parse(data);
+
} catch (error) {
+
return [];
+
}
+
}
+
+
export async function updateCache(
+
event: AppleMusicPlaybackEvent,
+
): Promise<void> {
+
const cache = await getCache();
+
cache.push({
+
songName: event["Song Name"],
+
timestamp: event["Event End Timestamp"],
+
});
+
await writeFile(cacheFilePath, JSON.stringify(cache, null, 2));
+
}
+128
src/utils/data.ts
···
+
import type {
+
AppleMusicContainer,
+
AppleMusicPlaybackEvent,
+
AppleMusicTrackHistory,
+
} from "../types";
+
import * as Papaparse from "papaparse";
+
import { existsSync } from "fs";
+
+
const trackCache = new Map<
+
string,
+
{ originId: string | null; artist: string }
+
>();
+
+
type OverridesMap = Record<string, string>;
+
let overrides: OverridesMap = {};
+
if (existsSync("artist_overrides.json")) {
+
const rawOverrides = await Bun.file("artist_overrides.json")
+
.json() as OverridesMap;
+
overrides = Object.fromEntries(
+
Object.entries(rawOverrides).map((
+
[key, value],
+
) => [key.toLowerCase(), value]),
+
);
+
}
+
+
export async function parseCSV(dataExportPath: string, fileName: string) {
+
const cachedPath = `cache/${fileName}.json`;
+
if (existsSync(cachedPath)) {
+
return await Bun.file(cachedPath).json();
+
}
+
const csv = await Bun.file(`${dataExportPath}/${fileName}.csv`).text();
+
+
const parsed = Papaparse.parse(csv, {
+
header: true,
+
});
+
+
const data = parsed.data;
+
+
await Bun.write(cachedPath, JSON.stringify(data));
+
return data;
+
}
+
+
export function isEligible(event: AppleMusicPlaybackEvent): boolean {
+
// Must be a play end event
+
if (event["Event Type"] !== "PLAY_END") {
+
return false;
+
}
+
+
// Check end reason - should be a natural completion or user skip to next track
+
const endReason = event["End Reason Type"];
+
const validEndReasons = [
+
"NATURAL_END_OF_TRACK",
+
"TRACK_SKIPPED_FORWARDS",
+
"MANUALLY_SELECTED_PLAYBACK_OF_A_DIFF_ITEM",
+
];
+
+
if (!validEndReasons.includes(endReason)) {
+
return false;
+
}
+
+
// Get media duration in milliseconds
+
const mediaDuration = parseInt(event["Media Duration In Milliseconds"]);
+
+
// Track must be longer than 30 seconds
+
if (isNaN(mediaDuration) || mediaDuration <= 30000) {
+
return false;
+
}
+
+
// Calculate actual play duration
+
const playDuration = parseInt(event["Play Duration Milliseconds"]);
+
+
if (isNaN(playDuration)) {
+
return false;
+
}
+
+
// Last.fm scrobbling rules:
+
// Track must be played for at least 4 minutes (240000ms) OR 50% of duration
+
const minimumDuration = Math.min(240000, mediaDuration * 0.5);
+
+
if (playDuration < minimumDuration) {
+
return false;
+
}
+
+
return true;
+
}
+
+
export function buildTrackCache(trackHistory: AppleMusicTrackHistory[]): void {
+
trackCache.clear();
+
+
for (const track of trackHistory) {
+
const [artist, name] = (track["Track Description"] || "").split(" - ");
+
if (name) {
+
const normalizedName = name.toLowerCase();
+
// Only store first occurrence (like original code)
+
if (!trackCache.has(normalizedName)) {
+
trackCache.set(normalizedName, {
+
originId: track["Track Identifier"] === "0"
+
? null
+
: track["Track Identifier"],
+
artist: artist!,
+
});
+
}
+
}
+
}
+
}
+
+
export function getExtraSongData(
+
trackName: string,
+
): {
+
originId: string | null;
+
artist: string;
+
} | null {
+
const normalizedTrackName = trackName.toLowerCase();
+
+
const cached = trackCache.get(normalizedTrackName);
+
if (cached) {
+
return cached;
+
}
+
+
if (overrides[normalizedTrackName]) {
+
return {
+
originId: null,
+
artist: overrides[normalizedTrackName],
+
};
+
}
+
+
return null;
+
}
+434
src/utils/musicbrainz.ts
···
+
/*
+
A large portion of the recording/release determination, search query parsing, and
+
caching was done by AI. So excuse the mess lol, I had to keep asking Claude for fixes
+
after runs failed in the middle due to a bad search query/
+
*/
+
import {
+
type IArtistCredit,
+
type IRecording,
+
type IRecordingMatch,
+
type IRelease,
+
MusicBrainzApi,
+
} from "musicbrainz-api";
+
import { env } from "../env";
+
import { join } from "path";
+
import crypto from "crypto";
+
import { createStorage } from "unstorage";
+
import fsDriver from "unstorage/drivers/fs";
+
+
const storage = createStorage({
+
driver: fsDriver({ base: join(process.cwd(), "cache", "musicbrainz") }),
+
});
+
+
async function cached<T>(
+
key: string,
+
fn: () => Promise<T>,
+
): Promise<T> {
+
const keyHash = crypto.createHash("sha256").update(key).digest("hex");
+
+
const cachedData = await storage.getItem(keyHash);
+
if (cachedData) {
+
console.log(`[CACHE] HIT: ${key}`);
+
return cachedData as T;
+
}
+
+
console.log(`[CACHE] MISS: ${key}`);
+
const result = await fn();
+
+
// cast to StorageValue-compatible type to satisfy unstorage typings
+
await storage.setItem(keyHash, result as unknown as any);
+
+
return result;
+
}
+
+
export const mbAPI = new MusicBrainzApi({
+
appName: "Index's Teal.fm Apple Music Importer",
+
appVersion: "0.1",
+
appContactInfo: env.MUSICBRAINZ_CONTACT_EMAIL,
+
});
+
+
function sleep(ms: number) {
+
return new Promise((resolve) => setTimeout(resolve, ms));
+
}
+
+
async function retry<T>(
+
fn: () => Promise<T>,
+
retries = 3,
+
delay = 1000,
+
): Promise<T> {
+
try {
+
return await fn();
+
} catch (error) {
+
if (retries > 0) {
+
console.warn(
+
`Function failed, retrying in ${delay}ms... (${retries} retries left)`,
+
);
+
await sleep(delay);
+
return retry(fn, retries - 1, delay * 2); // Exponential backoff
+
}
+
throw error;
+
}
+
}
+
+
export async function lookupTrack(
+
artistName: string,
+
albumName: string,
+
trackName: string,
+
): Promise<{ recording: IRecordingMatch; release: IRelease } | null> {
+
const cacheKey = `lookupTrack:${artistName}:${albumName}:${trackName}`;
+
+
return cached(cacheKey, async () => {
+
const primaryArtist = getPrimaryArtist(artistName);
+
const normalizedTrack = normalizeForSearch(stripFeatures(trackName));
+
+
const query = await retry(() =>
+
mbAPI.search("recording", {
+
query: `artist:(${primaryArtist}) AND recording:(${
+
stripFeatures(trackName)
+
})`,
+
limit: 25,
+
})
+
);
+
+
console.log(
+
`artist:"${
+
normalizeForSearch(artistName)
+
}" AND recording:"${normalizedTrack}"`,
+
);
+
+
const candidates: Array<{
+
recording: any;
+
release: any;
+
score: number;
+
}> = [];
+
const albumNorm = normalizeKey(albumName);
+
const trackNorm = normalizeKey(trackName);
+
+
for (const recording of query.recordings) {
+
const artists = recording["artist-credit"];
+
if (!artists || !matchArtist(artistName, artists)) {
+
console.log("Artist doesn't match", recording.id);
+
continue;
+
}
+
+
console.log(recording.title);
+
+
if (
+
normalizeKey(stripFeatures(recording.title)) !==
+
normalizeKey(stripFeatures(trackName))
+
) {
+
console.log("Not same title", recording.id);
+
continue;
+
}
+
+
for (const release of recording.releases || []) {
+
if (release.status !== "Official") {
+
console.log("Release not official", release.id);
+
continue;
+
}
+
+
const releaseNormRaw = (release.title || "").toLowerCase(); // raw text
+
const variantKeywords = [
+
"track by track",
+
"commentary",
+
"bonus",
+
"deluxe",
+
"expanded",
+
"remix",
+
"edition",
+
"remastered",
+
"clean",
+
"instrumental",
+
"edited",
+
];
+
const hasVariant = variantKeywords.some((k) =>
+
releaseNormRaw.includes(k)
+
);
+
+
const releaseNorm = normalizeKey(release.title);
+
let score = 0;
+
+
// --- Perfect match (only if no variant) ---
+
if (releaseNorm === albumNorm && !hasVariant) {
+
score += 5;
+
console.log("Perfect match:", release.title);
+
return { recording, release };
+
}
+
+
// Base scoring for partial match
+
if (releaseNorm.startsWith(albumNorm)) score += 2;
+
+
// Penalize variants
+
if (hasVariant) score -= 3;
+
+
// Bonus for release date
+
if (release.date) score += 1;
+
+
// Explicit vs Clean bias
+
const variantText = [
+
release.title,
+
release.disambiguation,
+
recording.title,
+
recording.disambiguation,
+
]
+
.filter(Boolean)
+
.join(" ")
+
.toLowerCase();
+
+
if (variantText.includes("explicit")) score += 2;
+
if (variantText.includes("clean")) score -= 2;
+
+
// Log candidate score
+
console.log(
+
`→ ${release.title} [${release.id}] | norm="${releaseNorm}" | variant=${hasVariant} | score=${score}`,
+
);
+
+
candidates.push({ recording, release, score });
+
}
+
}
+
+
// --- Fallback: pick best candidate ---
+
if (candidates.length > 0) {
+
candidates.sort((a, b) => b.score - a.score);
+
const topScore = candidates[0]!.score;
+
const topEqual = candidates.filter((c) => c.score === topScore);
+
+
// Tie-breaker: prefer explicit
+
let best = topEqual.find((c) =>
+
["explicit"].some((k) =>
+
(
+
c.release.title +
+
c.recording.title +
+
(c.release.disambiguation ?? "")
+
)
+
.toLowerCase()
+
.includes(k)
+
)
+
);
+
+
if (!best) best = topEqual[0]!;
+
+
console.log(
+
`Best fallback match: ${best.release.title} (score ${best.score})`,
+
);
+
return best;
+
}
+
+
console.log("No suitable match found.");
+
return null;
+
});
+
}
+
+
export async function getAppleMusicURL(recording: IRecording) {
+
const cacheKey = `getAppleMusicURL:${recording.id}`;
+
+
return cached(cacheKey, async () => {
+
const query = await retry(() =>
+
mbAPI.lookup("recording", recording.id, ["url-rels"])
+
);
+
+
const relations = query.relations;
+
if (!relations) return "";
+
+
const appleMusic = relations.find((relation) =>
+
relation.type == "streaming" &&
+
relation.url?.resource.includes("music.apple.com/us/song/")
+
);
+
+
const url = appleMusic?.url?.resource;
+
+
return url;
+
});
+
}
+
+
function matchArtist(artistInput: string, artistCredits: any[]): boolean {
+
const artistInputNorm = normalizeKey(artistInput);
+
+
for (const credit of artistCredits) {
+
const mbArtist = credit.artist ?? credit;
+
if (!mbArtist?.name) continue;
+
const artistKey = normalizeKey(mbArtist.name);
+
+
// Exact match
+
if (artistKey === artistInputNorm) return true;
+
+
// Substring match (handles artist name variations)
+
if (
+
artistInputNorm.includes(artistKey) ||
+
artistKey.includes(artistInputNorm)
+
) {
+
return true;
+
}
+
+
// Check aliases
+
for (const alias of mbArtist["alias-list"] || []) {
+
const aliasKey = normalizeKey(alias.alias);
+
if (aliasKey === artistInputNorm) return true;
+
if (
+
artistInputNorm.includes(aliasKey) ||
+
aliasKey.includes(artistInputNorm)
+
) {
+
return true;
+
}
+
}
+
}
+
+
// Multi-artist logic (from earlier fix)
+
const separatorRegex = /\s*(?:&|feat\.?|featuring|ft\.?|with|x|,)\s*/i;
+
const inputArtists = artistInput.split(separatorRegex).map((a) =>
+
normalizeKey(a.trim())
+
);
+
+
if (inputArtists.length > 1) {
+
const creditNames = artistCredits.map((credit) => {
+
const mbArtist = credit.artist ?? credit;
+
return normalizeKey(mbArtist?.name || "");
+
}).filter(Boolean);
+
+
const allMatch = inputArtists.every((inputArtist) =>
+
creditNames.some((creditName) =>
+
creditName === inputArtist ||
+
creditName.includes(inputArtist) ||
+
inputArtist.includes(creditName)
+
)
+
);
+
+
if (allMatch) return true;
+
}
+
+
console.log(
+
"Not same artist:",
+
artistCredits.map((c) => normalizeKey((c.artist ?? c)?.name)).join(
+
", ",
+
),
+
"!=",
+
artistInputNorm,
+
);
+
return false;
+
}
+
+
function normalizeKey(s?: string): string {
+
if (!s) return "";
+
+
s = s.normalize("NFKD").toLowerCase().trim();
+
s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, ""); // remove accents
+
s = s.replace(/[’‘]/g, "'").replace(/[“”]/g, '"');
+
+
const replacements: Record<string, string> = {
+
"\\boriginal motion picture soundtrack\\b": "ost",
+
"\\boriginal soundtrack\\b": "ost",
+
"\\bsoundtrack\\b": "ost",
+
"\\bvol(\\.|ume)?\\b": "vol",
+
"\\bpart\\b": "pt",
+
"\\bparts\\b": "pt",
+
"\\bedition\\b": "",
+
"\\bthe\\b": "",
+
"\\band\\b": "",
+
"\\bep\\b": "",
+
"\\bwalt disney records\\b": "",
+
"\\blegacy collection\\b": "",
+
"\\bgreatest hits\\b": "",
+
"\\breissue(d)?\\b": "",
+
"\\bre-issue(d)?\\b": "",
+
"\\bsong of the\\b": "",
+
"\\bost\\b": "",
+
"\\bdeluxe\\b": "",
+
"\\btrack by track\\b": "",
+
"\\bcommentary\\b": "",
+
"\\bversion\\b": "",
+
};
+
+
for (const [pattern, repl] of Object.entries(replacements)) {
+
s = s.replace(new RegExp(pattern, "gi"), repl);
+
}
+
+
// Remove parenthetical/extra info
+
s = s.replace(/\(.*?\)/g, "");
+
s = s.replace(/\[.*?\]/g, "");
+
s = s.replace(
+
/[-–:]\s*(remaster(ed)?|ep|single|deluxe|expanded|anniversary|commentary|track by track|bonus|edition|version).*$/gi,
+
"",
+
);
+
s = s.replace(
+
/\s*-\s*(cover|live|remaster|remix|version|edit|single|mono|stereo|mix|feat.*)$/gi,
+
"",
+
);
+
+
// Roman numerals → numbers
+
const romanMap: Record<string, string> = {
+
xx: "20",
+
xix: "19",
+
xviii: "18",
+
xvii: "17",
+
xvi: "16",
+
xv: "15",
+
xiv: "14",
+
xiii: "13",
+
xii: "12",
+
xi: "11",
+
x: "10",
+
ix: "9",
+
viii: "8",
+
vii: "7",
+
vi: "6",
+
v: "5",
+
iv: "4",
+
iii: "3",
+
ii: "2",
+
i: "1",
+
};
+
for (const [roman, num] of Object.entries(romanMap)) {
+
s = s.replace(new RegExp(`\\b${roman}\\b`, "gi"), num);
+
}
+
+
// Words → numbers
+
const wordNums: Record<string, string> = {
+
one: "1",
+
two: "2",
+
three: "3",
+
four: "4",
+
five: "5",
+
six: "6",
+
seven: "7",
+
eight: "8",
+
nine: "9",
+
ten: "10",
+
eleven: "11",
+
twelve: "12",
+
thirteen: "13",
+
fourteen: "14",
+
fifteen: "15",
+
sixteen: "16",
+
seventeen: "17",
+
eighteen: "18",
+
nineteen: "19",
+
twenty: "20",
+
};
+
for (const [word, num] of Object.entries(wordNums)) {
+
s = s.replace(new RegExp(`\\b${word}\\b`, "gi"), num);
+
}
+
+
s = s.replace(/[^\w\s]/g, "");
+
s = s.replace(/\s+/g, "");
+
+
return s;
+
}
+
+
function stripFeatures(title: string): string {
+
return title.replace(/\s*[\(\[]feat\.?.*?[\)\]]/gi, "").trim();
+
}
+
+
function normalizeForSearch(str: string): string {
+
// Remove apostrophes and other punctuation that causes search issues
+
return str.replace(/['']/g, "").trim();
+
}
+
+
function escapeSearchQuery(str: string): string {
+
// Escape backslashes and quotes for Lucene
+
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+
}
+
+
function getPrimaryArtist(artistName: string): string {
+
return artistName.split(/\s*(?:&|feat\.?|featuring|ft\.?|with|x|,)\s*/i)[0]!
+
.trim();
+
}
+33
tsconfig.json
···
+
{
+
"compilerOptions": {
+
// Environment setup & latest features
+
"lib": ["ESNext"],
+
"target": "ESNext",
+
"module": "Preserve",
+
"moduleDetection": "force",
+
"jsx": "react-jsx",
+
"allowJs": true,
+
+
// Bundler mode
+
"moduleResolution": "bundler",
+
"allowImportingTsExtensions": true,
+
"verbatimModuleSyntax": true,
+
"noEmit": true,
+
+
// Best practices
+
"strict": true,
+
"skipLibCheck": true,
+
"noFallthroughCasesInSwitch": true,
+
"noUncheckedIndexedAccess": true,
+
"noImplicitOverride": true,
+
+
// Some stricter flags (disabled by default)
+
"noUnusedLocals": false,
+
"noUnusedParameters": false,
+
"noPropertyAccessFromIndexSignature": false
+
},
+
"exclude": [
+
"node_modules",
+
"cache"
+
]
+
}