mirror of
https://notabug.org/SuperSaltyGamer/ame
synced 2026-01-15 16:52:54 -03:00
Refactor everything
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"useTabs": true,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
44
README.md
44
README.md
@@ -2,15 +2,53 @@
|
||||
|
||||
Various userscripts for the music hoarding community.
|
||||
|
||||
### Getting Started
|
||||
## Getting Started
|
||||
|
||||
Install [ViolentMonkey](https://violentmonkey.github.io) or [TamperMonkey](https://tampermonkey.net) then proceed to installing any of the following userscripts:
|
||||
Install [ViolentMonkey](https://violentmonkey.github.io) or [TamperMonkey](https://tampermonkey.net) then proceed to installing any of the editions:
|
||||
|
||||
* [Apple Music](https://notabug.org/SuperSaltyGamer/ame/raw/main/dist/applemusic.user.js)
|
||||
* [MusicBrainz](https://notabug.org/SuperSaltyGamer/ame/raw/main/dist/musicbrainz.user.js)
|
||||
* [VGMdb](https://notabug.org/SuperSaltyGamer/ame/raw/main/dist/vgmdb.user.js)
|
||||
|
||||
### References
|
||||
## Editions
|
||||
|
||||
Feature rundown for all editions of Ame.
|
||||
|
||||
### Ame (Apple Music)
|
||||
|
||||
* Selectable release title, artist, description.
|
||||
* Check release storefront availability.
|
||||
* Link to full resolution release cover.
|
||||
* Hide upselling modals and banners.
|
||||
* Extended release info panel.
|
||||
* Copy authorization token.
|
||||
* Release quality badges.
|
||||
* Check track quality.
|
||||
* Lyrics downloading.
|
||||
* Search MH Covers.
|
||||
|
||||
### Ame (MusicBrainz)
|
||||
|
||||
* Add covers directly from MH Covers.
|
||||
* Batch download release scans.
|
||||
* Enhanced search box:
|
||||
* Automatic focus on page load.
|
||||
* Search directly by ISRC, Catalog Number, Barcode.
|
||||
* Search directly by EAC/XLD rip log.
|
||||
* Attach TOC from rip log on release page.
|
||||
* Related links:
|
||||
* Ongaku no Mori link on release page.
|
||||
* MH Covers link on release page.
|
||||
|
||||
### Ame (VGMdb)
|
||||
|
||||
* Seed release to MusicBrainz.
|
||||
* Batch download album scans.
|
||||
* Related links:
|
||||
* Ongaku no Mori link on release page.
|
||||
* MusicBrainz link on release page.
|
||||
|
||||
## References
|
||||
|
||||
Inspired by and/or used for reference:
|
||||
|
||||
|
||||
468
dist/applemusic.user.js
vendored
468
dist/applemusic.user.js
vendored
File diff suppressed because one or more lines are too long
32
dist/musicbrainz.user.js
vendored
32
dist/musicbrainz.user.js
vendored
File diff suppressed because one or more lines are too long
22
dist/vgmdb.user.js
vendored
22
dist/vgmdb.user.js
vendored
File diff suppressed because one or more lines are too long
226
package-lock.json
generated
226
package-lock.json
generated
@@ -10,12 +10,10 @@
|
||||
"dependencies": {
|
||||
"handsontable": "^13.0.0",
|
||||
"jszip": "^3.9.1",
|
||||
"lighterhtml": "^4.2.0",
|
||||
"path-to-regexp": "^6.2.1",
|
||||
"xml-formatter": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tampermonkey": "^4.0.5",
|
||||
"@types/tampermonkey": "^4.0.10",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.7"
|
||||
}
|
||||
@@ -389,31 +387,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tampermonkey": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tampermonkey/-/tampermonkey-4.0.5.tgz",
|
||||
"integrity": "sha512-FGPo7d+qZkDF7vyrwY1WNhcUnfDyVpt2uyL7krAu3WKCUMCfIUzOuvt8aSk8N2axHT8XPr9stAEDGVHLvag6Pw==",
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/tampermonkey/-/tampermonkey-4.0.10.tgz",
|
||||
"integrity": "sha512-E3SYtXgeXG/nnq6uAPiZh7i0XA8jLbtiXraxxHTnsSjzQcQMxWBzbcoGkHgiC+zbHXxxkynUT9zt85SpF8loNw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@ungap/create-content": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/create-content/-/create-content-0.2.0.tgz",
|
||||
"integrity": "sha512-CvmX0Mr5PfFARDBbSef0B+SAqSeMKaHOG/twJi9nbPtp/MiNPgyBLqZndiyO3RXQ0RXy6TqwarvB6KWzTmc4MQ=="
|
||||
},
|
||||
"node_modules/@ungap/import-node": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/import-node/-/import-node-0.2.0.tgz",
|
||||
"integrity": "sha512-VuWVBAMRjoOc63n8Cc19brS7KlhYJ+57790LF+lVw60nMRemCrz1T6HnoNx74IEW3FS+TM+vveJ70C6NyTKODQ=="
|
||||
},
|
||||
"node_modules/@ungap/trim": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/trim/-/trim-0.2.0.tgz",
|
||||
"integrity": "sha512-CfsUxeZ2R/O3EGCOe+IkAU32yHOdO+mCRmtavSIQ4HZN3Jiq/ynGzq8/asyamd28U26UJmpSV/TC7+p7qELKrg=="
|
||||
},
|
||||
"node_modules/@ungap/weakmap": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/weakmap/-/weakmap-0.2.1.tgz",
|
||||
"integrity": "sha512-GmVAWB+JuFKqSbzlofYK4qxk955gEv4Kd9/aj2hLOxneXMAm/J7OXcl5DlElS9tmkqwCcxGysSZGOrjzNvmjFQ=="
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-8.1.1.tgz",
|
||||
@@ -446,38 +424,11 @@
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"node_modules/domconstants": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/domconstants/-/domconstants-0.1.2.tgz",
|
||||
"integrity": "sha512-sPOoOckTxtwy5t8PFf6zl11gOEhOpl1k0ZCc/NfCNmHoMw8n9HnCQCzxWKX9gdBp+qM+2DTFkst++Yw6C41izQ=="
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.7.tgz",
|
||||
"integrity": "sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ=="
|
||||
},
|
||||
"node_modules/domsanitizer": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/domsanitizer/-/domsanitizer-0.2.3.tgz",
|
||||
"integrity": "sha512-qglHc+5k5C2+WSEzck0WGbpa2exCKZuZKb0n4RU2Wuy7BPkpmf67mHD1BJWGxs0iJYl709f2YVeJMh06c1ILlA==",
|
||||
"dependencies": {
|
||||
"domconstants": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/domtagger": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/domtagger/-/domtagger-0.7.2.tgz",
|
||||
"integrity": "sha512-h7g5eduvnLwowJJPkcB5lNzo8vd/Hx4e3I4IOtLpX0qB2wBiuryGLNa61MeFre4b6gMaQIhegMIZ2I8rQCAJwQ==",
|
||||
"dependencies": {
|
||||
"@ungap/create-content": "^0.2.0",
|
||||
"@ungap/import-node": "^0.2.0",
|
||||
"@ungap/trim": "^0.2.0",
|
||||
"@ungap/weakmap": "^0.2.1",
|
||||
"domconstants": "^0.1.2",
|
||||
"domsanitizer": "^0.2.2",
|
||||
"umap": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.18.17",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.17.tgz",
|
||||
@@ -556,11 +507,6 @@
|
||||
"unorm": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hyperhtml-style": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/hyperhtml-style/-/hyperhtml-style-0.1.3.tgz",
|
||||
"integrity": "sha512-IvLy8MzHTSJ0fDpSzrb8rcdnla6yROEmNBSxInEMyIFu2DQkbmpadTf6B4fHvnytN6iHL2gGwpe5/jHL3wMi+A=="
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -595,30 +541,6 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lighterhtml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lighterhtml/-/lighterhtml-4.2.0.tgz",
|
||||
"integrity": "sha512-HAb+Ri17iT+vYmFtarlt45O63BltoJY/ltDZGhnf2A1s4kno2j6su5KiZAgYD4/5AjODYQqflSy9KJZFwL5VwQ==",
|
||||
"dependencies": {
|
||||
"@ungap/create-content": "^0.2.0",
|
||||
"@ungap/weakmap": "^0.2.1",
|
||||
"domsanitizer": "^0.2.3",
|
||||
"domtagger": "^0.7.1",
|
||||
"hyperhtml-style": "^0.1.2",
|
||||
"udomdiff": "^1.1.0",
|
||||
"uhandlers": "^0.4.2",
|
||||
"umap": "^1.0.2",
|
||||
"uwire": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lighterhtml/node_modules/uhandlers": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/uhandlers/-/uhandlers-0.4.2.tgz",
|
||||
"integrity": "sha512-4M3yo0saEReMHiUz3yTDX/e9Z1Z+X8fVvDEywdjvWHPKqzpI6xEPJ21llrBf6Tvf7E5xaPCf9l5JXaYZRU7QRA==",
|
||||
"dependencies": {
|
||||
"uarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
@@ -661,11 +583,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
|
||||
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw=="
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
@@ -795,21 +712,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uarray/-/uarray-1.0.0.tgz",
|
||||
"integrity": "sha512-LHmiAd5QuAv7pU2vbh+Zq9YOnqVK0H764p2Ozinpfy9ka58OID4IsGLiXsitqH7n0NAIDxvax1A/kDXpii/Ckg=="
|
||||
},
|
||||
"node_modules/udomdiff": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/udomdiff/-/udomdiff-1.1.0.tgz",
|
||||
"integrity": "sha512-aqjTs5x/wsShZBkVagdafJkP8S3UMGhkHKszsu1cszjjZ7iOp86+Qb3QOFYh01oWjPMy5ZTuxD6hw5uTKxd+VA=="
|
||||
},
|
||||
"node_modules/umap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/umap/-/umap-1.0.2.tgz",
|
||||
"integrity": "sha512-bW127HgG4H4VAD6qlqO5vCC+7bnlYvZ6A6BdwyGblkWvlEG7VYpj1bcpf3iJpvyKmkPZWDIeZDmoULz67ec7NA=="
|
||||
},
|
||||
"node_modules/unorm": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz",
|
||||
@@ -824,14 +726,6 @@
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uwire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uwire/-/uwire-1.1.0.tgz",
|
||||
"integrity": "sha512-XJPmJnySabt8D0/wnfFFywgUOBnXszDEW32nEVIfOx1n6gLTZSp+X+70+blSnHKNiIUVSFPmmRuxOal0I/aB5g==",
|
||||
"dependencies": {
|
||||
"uarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "4.4.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.4.7.tgz",
|
||||
@@ -1079,31 +973,11 @@
|
||||
}
|
||||
},
|
||||
"@types/tampermonkey": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tampermonkey/-/tampermonkey-4.0.5.tgz",
|
||||
"integrity": "sha512-FGPo7d+qZkDF7vyrwY1WNhcUnfDyVpt2uyL7krAu3WKCUMCfIUzOuvt8aSk8N2axHT8XPr9stAEDGVHLvag6Pw==",
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/tampermonkey/-/tampermonkey-4.0.10.tgz",
|
||||
"integrity": "sha512-E3SYtXgeXG/nnq6uAPiZh7i0XA8jLbtiXraxxHTnsSjzQcQMxWBzbcoGkHgiC+zbHXxxkynUT9zt85SpF8loNw==",
|
||||
"dev": true
|
||||
},
|
||||
"@ungap/create-content": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/create-content/-/create-content-0.2.0.tgz",
|
||||
"integrity": "sha512-CvmX0Mr5PfFARDBbSef0B+SAqSeMKaHOG/twJi9nbPtp/MiNPgyBLqZndiyO3RXQ0RXy6TqwarvB6KWzTmc4MQ=="
|
||||
},
|
||||
"@ungap/import-node": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/import-node/-/import-node-0.2.0.tgz",
|
||||
"integrity": "sha512-VuWVBAMRjoOc63n8Cc19brS7KlhYJ+57790LF+lVw60nMRemCrz1T6HnoNx74IEW3FS+TM+vveJ70C6NyTKODQ=="
|
||||
},
|
||||
"@ungap/trim": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/trim/-/trim-0.2.0.tgz",
|
||||
"integrity": "sha512-CfsUxeZ2R/O3EGCOe+IkAU32yHOdO+mCRmtavSIQ4HZN3Jiq/ynGzq8/asyamd28U26UJmpSV/TC7+p7qELKrg=="
|
||||
},
|
||||
"@ungap/weakmap": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/weakmap/-/weakmap-0.2.1.tgz",
|
||||
"integrity": "sha512-GmVAWB+JuFKqSbzlofYK4qxk955gEv4Kd9/aj2hLOxneXMAm/J7OXcl5DlElS9tmkqwCcxGysSZGOrjzNvmjFQ=="
|
||||
},
|
||||
"bignumber.js": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-8.1.1.tgz",
|
||||
@@ -1128,38 +1002,11 @@
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"domconstants": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/domconstants/-/domconstants-0.1.2.tgz",
|
||||
"integrity": "sha512-sPOoOckTxtwy5t8PFf6zl11gOEhOpl1k0ZCc/NfCNmHoMw8n9HnCQCzxWKX9gdBp+qM+2DTFkst++Yw6C41izQ=="
|
||||
},
|
||||
"dompurify": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.7.tgz",
|
||||
"integrity": "sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ=="
|
||||
},
|
||||
"domsanitizer": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/domsanitizer/-/domsanitizer-0.2.3.tgz",
|
||||
"integrity": "sha512-qglHc+5k5C2+WSEzck0WGbpa2exCKZuZKb0n4RU2Wuy7BPkpmf67mHD1BJWGxs0iJYl709f2YVeJMh06c1ILlA==",
|
||||
"requires": {
|
||||
"domconstants": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"domtagger": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/domtagger/-/domtagger-0.7.2.tgz",
|
||||
"integrity": "sha512-h7g5eduvnLwowJJPkcB5lNzo8vd/Hx4e3I4IOtLpX0qB2wBiuryGLNa61MeFre4b6gMaQIhegMIZ2I8rQCAJwQ==",
|
||||
"requires": {
|
||||
"@ungap/create-content": "^0.2.0",
|
||||
"@ungap/import-node": "^0.2.0",
|
||||
"@ungap/trim": "^0.2.0",
|
||||
"@ungap/weakmap": "^0.2.1",
|
||||
"domconstants": "^0.1.2",
|
||||
"domsanitizer": "^0.2.2",
|
||||
"umap": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.18.17",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.17.tgz",
|
||||
@@ -1222,11 +1069,6 @@
|
||||
"unorm": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"hyperhtml-style": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/hyperhtml-style/-/hyperhtml-style-0.1.3.tgz",
|
||||
"integrity": "sha512-IvLy8MzHTSJ0fDpSzrb8rcdnla6yROEmNBSxInEMyIFu2DQkbmpadTf6B4fHvnytN6iHL2gGwpe5/jHL3wMi+A=="
|
||||
},
|
||||
"immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -1261,32 +1103,6 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"lighterhtml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lighterhtml/-/lighterhtml-4.2.0.tgz",
|
||||
"integrity": "sha512-HAb+Ri17iT+vYmFtarlt45O63BltoJY/ltDZGhnf2A1s4kno2j6su5KiZAgYD4/5AjODYQqflSy9KJZFwL5VwQ==",
|
||||
"requires": {
|
||||
"@ungap/create-content": "^0.2.0",
|
||||
"@ungap/weakmap": "^0.2.1",
|
||||
"domsanitizer": "^0.2.3",
|
||||
"domtagger": "^0.7.1",
|
||||
"hyperhtml-style": "^0.1.2",
|
||||
"udomdiff": "^1.1.0",
|
||||
"uhandlers": "^0.4.2",
|
||||
"umap": "^1.0.2",
|
||||
"uwire": "^1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"uhandlers": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/uhandlers/-/uhandlers-0.4.2.tgz",
|
||||
"integrity": "sha512-4M3yo0saEReMHiUz3yTDX/e9Z1Z+X8fVvDEywdjvWHPKqzpI6xEPJ21llrBf6Tvf7E5xaPCf9l5JXaYZRU7QRA==",
|
||||
"requires": {
|
||||
"uarray": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
@@ -1311,11 +1127,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"path-to-regexp": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
|
||||
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw=="
|
||||
},
|
||||
"picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
@@ -1408,21 +1219,6 @@
|
||||
"integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
|
||||
"dev": true
|
||||
},
|
||||
"uarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uarray/-/uarray-1.0.0.tgz",
|
||||
"integrity": "sha512-LHmiAd5QuAv7pU2vbh+Zq9YOnqVK0H764p2Ozinpfy9ka58OID4IsGLiXsitqH7n0NAIDxvax1A/kDXpii/Ckg=="
|
||||
},
|
||||
"udomdiff": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/udomdiff/-/udomdiff-1.1.0.tgz",
|
||||
"integrity": "sha512-aqjTs5x/wsShZBkVagdafJkP8S3UMGhkHKszsu1cszjjZ7iOp86+Qb3QOFYh01oWjPMy5ZTuxD6hw5uTKxd+VA=="
|
||||
},
|
||||
"umap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/umap/-/umap-1.0.2.tgz",
|
||||
"integrity": "sha512-bW127HgG4H4VAD6qlqO5vCC+7bnlYvZ6A6BdwyGblkWvlEG7VYpj1bcpf3iJpvyKmkPZWDIeZDmoULz67ec7NA=="
|
||||
},
|
||||
"unorm": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz",
|
||||
@@ -1434,14 +1230,6 @@
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"uwire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uwire/-/uwire-1.1.0.tgz",
|
||||
"integrity": "sha512-XJPmJnySabt8D0/wnfFFywgUOBnXszDEW32nEVIfOx1n6gLTZSp+X+70+blSnHKNiIUVSFPmmRuxOal0I/aB5g==",
|
||||
"requires": {
|
||||
"uarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"vite": {
|
||||
"version": "4.4.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.4.7.tgz",
|
||||
|
||||
@@ -10,12 +10,10 @@
|
||||
"dependencies": {
|
||||
"handsontable": "^13.0.0",
|
||||
"jszip": "^3.9.1",
|
||||
"lighterhtml": "^4.2.0",
|
||||
"path-to-regexp": "^6.2.1",
|
||||
"xml-formatter": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tampermonkey": "^4.0.5",
|
||||
"@types/tampermonkey": "^4.0.10",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.7"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { basename, dirname } from "path";
|
||||
import { join, basename, dirname } from "path";
|
||||
import { LibraryFormats, Plugin, ResolvedConfig } from "vite";
|
||||
|
||||
export interface UserScriptOptions {
|
||||
@@ -56,10 +56,9 @@ export function UserScriptPlugin(options: UserScriptOptions): Plugin {
|
||||
if (config.mode === "production") {
|
||||
if (options.cdn) url = `${options.cdn}${chunk.name}.user.js`;
|
||||
} else {
|
||||
if (options.port) url = `http://localhost:${options.port}/${chunk.name}.user.js`;
|
||||
if (options.port) url = `http://localhost:${join(options.port.toString(), config.build.outDir, chunk.name).replaceAll("\\", "/")}.user.js`;
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
if (url) {
|
||||
header = header.replaceAll(
|
||||
`// ==/UserScript==`,
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 78 20" height="32">
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path d="M43.15 3.42a1.294 1.294 0 0 0-1.119.665 1.29 1.29 0 0 0-.161.655h2.5a1.206 1.206 0 0 0-.577-1.144 1.205 1.205 0 0 0-.643-.176ZM25 2.27h-.08l-1 2.79h2Zm11.1 1.2c-.83 0-1.34.66-1.34 1.71 0 1.05.51 1.71 1.34 1.71.83 0 1.33-.64 1.33-1.71 0-1.07-.55-1.71-1.38-1.71Zm-5.34 0c-.83 0-1.34.66-1.34 1.71 0 1.05.51 1.71 1.33 1.71.82 0 1.25-.64 1.25-1.71 0-1.07-.46-1.71-1.29-1.71Zm-5.29-2.36 2.38 6.53h-1.09l-.6-1.77h-2.51l-.6 1.77H22l2.37-6.53ZM40.12.8v6.84h-1V.8Zm3 1.83a2.172 2.172 0 0 1 2.21 2.48v.33h-3.5a1.327 1.327 0 0 0 .617 1.262c.211.131.455.199.703.198a1.183 1.183 0 0 0 1.18-.62h.94a1.999 1.999 0 0 1-2.15 1.41 2.245 2.245 0 0 1-2.093-1.276 2.238 2.238 0 0 1-.197-1.264 2.282 2.282 0 0 1 2.33-2.52ZM31 2.64c1.26 0 2.06 1 2.06 2.54S32.24 7.72 31 7.72a1.617 1.617 0 0 1-1.53-.82h-.07v2.37h-1V2.73h.94v.81h.07a1.738 1.738 0 0 1 1.59-.9Zm5.34 0c1.27 0 2.06 1 2.06 2.54s-.79 2.54-2.05 2.54a1.61 1.61 0 0 1-1.52-.82h-.08v2.37h-1V2.73h.94v.81h.07a1.753 1.753 0 0 1 1.56-.9Zm.26 8.27a.621.621 0 0 1 .557.893.625.625 0 0 1-.702.33.621.621 0 0 1 .125-1.223Zm-7.3 0a.621.621 0 0 1 .557.893.625.625 0 0 1-.702.33.621.621 0 0 1 .125-1.223Zm43 2.8A1.278 1.278 0 0 0 71 15h2.5a1.203 1.203 0 0 0-.587-1.119 1.214 1.214 0 0 0-.633-.171Zm-39.46 0c-.85 0-1.32.64-1.32 1.64 0 1 .47 1.65 1.32 1.65.85 0 1.34-.64 1.34-1.65 0-1.01-.53-1.59-1.36-1.59Zm-8-1.49h-1.31V17h1.24c1.37 0 2.17-.87 2.17-2.39a2.099 2.099 0 0 0-.2-1.177 2.096 2.096 0 0 0-1.97-1.163Zm36 3.41-1.21.09c-.68 0-1 .28-1 .73 0 .45.39.72.92.72a1.17 1.17 0 0 0 1.28-1.11ZM44 15.68l-1.21.09c-.68 0-1 .28-1 .73 0 .45.39.72.92.72A1.168 1.168 0 0 0 44 16.11ZM24.88 11.4a2.894 2.894 0 0 1 2.83 1.572c.261.515.362 1.096.29 1.668 0 2.08-1.13 3.28-3.09 3.28h-2.4V11.4Zm4.89 1.6v4.91h-1V13Zm21.58-1.6 2.09 5.1h.08l2.1-5.1h1.19v6.52h-1v-4.78h-.01l-1.93 4.67h-.77l-1.93-4.67h-.06v4.78h-1V11.4ZM47 11.09v6.83h-1v-6.83ZM37.07 13v4.91h-1V13Zm40.49-.08c.149.015.296.041.44.08v.92a2.427 2.427 0 0 0-.55-.06 1.174 1.174 0 0 0-1.079.541 1.165 1.165 0 0 0-.181.599v2.93h-1V13h.94v.75h.07a1.34 1.34 0 0 1 1.36-.82Zm-38.19-1.15V13h1.07v.77h-1.07v2.6c0 .52.21.75.7.75.123.011.247.011.37 0v.78a4.26 4.26 0 0 1-.53 0c-1.09 0-1.52-.37-1.52-1.32V13.8h-.79V13h.79v-1.23Zm29.17 0V13h1.07v.77h-1.07v2.6c0 .52.21.75.7.75.123.011.247.011.37 0v.78a4.26 4.26 0 0 1-.53 0c-1.09 0-1.52-.37-1.52-1.32V13.8h-.79V13h.79v-1.23Zm-8.73 1.15c1.25 0 1.95.61 1.95 1.64v3.36h-.94v-.69h-.08a1.678 1.678 0 0 1-1.49.77 1.493 1.493 0 0 1-1.424-.675 1.497 1.497 0 0 1-.236-.785c0-.88.67-1.39 1.85-1.47l1.34-.07v-.43c0-.53-.34-.84-1-.84-.66 0-.93.21-1 .56h-.94c.05-.83.85-1.37 1.97-1.37Zm-16.75 0c1.25 0 1.95.61 1.95 1.64v3.36h-.94v-.69H44a1.682 1.682 0 0 1-1.5.77 1.493 1.493 0 0 1-1.424-.675 1.497 1.497 0 0 1-.236-.785c0-.88.67-1.39 1.85-1.47L44 15v-.43c0-.53-.34-.84-1-.84-.66 0-.93.21-1 .56h-1c.14-.83.94-1.37 2.06-1.37Zm21.44 0c1.12 0 1.84.51 1.94 1.36h-.94c-.09-.36-.44-.6-1-.6s-1 .26-1 .66.25.49.79.62l.82.19c.94.22 1.39.62 1.39 1.34 0 .91-.87 1.53-2.06 1.53s-2-.53-2-1.39h1c.082.212.233.39.429.505.197.114.426.158.651.125.61 0 1-.28 1-.68s-.23-.51-.73-.63l-.87-.2c-.94-.22-1.38-.63-1.38-1.36 0-.73.84-1.47 1.96-1.47Zm7.79 0a2.158 2.158 0 0 1 2.21 2.47v.34H71a1.326 1.326 0 0 0 .62 1.253c.21.13.453.198.7.197a1.174 1.174 0 0 0 1.18-.61h.94A2 2 0 0 1 72.29 18a2.237 2.237 0 0 1-2.085-1.265A2.245 2.245 0 0 1 70 15.48a2.29 2.29 0 0 1 .205-1.263 2.28 2.28 0 0 1 2.085-1.297Zm-39.74 0a1.73 1.73 0 0 1 1.56.86h.07V13h.94v5c0 1.12-.89 1.83-2.29 1.83-1.24 0-2-.55-2.15-1.37h1c.08.37.49.6 1.15.6.82 0 1.3-.4 1.3-1.06v-1h-.07a1.665 1.665 0 0 1-.647.632 1.667 1.667 0 0 1-.883.198c-1.27 0-2.05-1-2.05-2.45 0-1.45.8-2.45 2.07-2.45ZM0 0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d="M17.48 1.22A2.78 2.78 0 0 1 19 3.88v11.25a2.722 2.722 0 0 1-1.34 2.59 2.204 2.204 0 0 1-1.1.28 4.2 4.2 0 0 1-2.68-1.24l-4.24-3.38a.81.81 0 0 1-.295-.757.805.805 0 0 1 .175-.383.84.84 0 0 1 1.16-.12l4.25 3.4c.81.69 1.52 1 1.9.77.14-.08.5-.28.5-1.18V3.88a1.229 1.229 0 0 0-.56-1.19c-.79-.36-1.72.63-2.08 1.07-.55.59-2.34 2.94-4.18 5.39l-.62.85-1.58 2.11c-1.52 2-2.84 3.81-3.31 4.38-1.23 1.7-2.47 1.66-3.29 1.33A2.998 2.998 0 0 1 0 14.76V9.59a2.785 2.785 0 0 1 1.45-2.78 3.084 3.084 0 0 1 3.42.83L6.47 9a.805.805 0 0 1 .28.761.795.795 0 0 1-.18.379.832.832 0 0 1-1.16.09L3.8 8.87c-1-.9-1.43-.71-1.62-.62-.19.09-.52.26-.53 1.35v5.16c0 .48.09 1.32.68 1.56.17.07.6.24 1.34-.78C4.13 15 5.45 13.2 7 11.15L7.85 10l.65-.87c2.16-2.89 4.25-5.65 4.94-6.4C15.26.54 16.87 1 17.48 1.22ZM43.15 3.42a1.294 1.294 0 0 0-1.119.665 1.29 1.29 0 0 0-.161.655h2.5a1.206 1.206 0 0 0-.577-1.144 1.205 1.205 0 0 0-.643-.176ZM25 2.27h-.08l-1 2.79h2Zm11.1 1.2c-.83 0-1.34.66-1.34 1.71 0 1.05.51 1.71 1.34 1.71.83 0 1.33-.64 1.33-1.71 0-1.07-.55-1.71-1.38-1.71Zm-5.34 0c-.83 0-1.34.66-1.34 1.71 0 1.05.51 1.71 1.33 1.71.82 0 1.25-.64 1.25-1.71 0-1.07-.46-1.71-1.29-1.71Zm-5.29-2.36 2.38 6.53h-1.09l-.6-1.77h-2.51l-.6 1.77H22l2.37-6.53ZM40.12.8v6.84h-1V.8Zm3 1.83a2.172 2.172 0 0 1 2.21 2.48v.33h-3.5a1.327 1.327 0 0 0 .617 1.262c.211.131.455.199.703.198a1.183 1.183 0 0 0 1.18-.62h.94a1.999 1.999 0 0 1-2.15 1.41 2.245 2.245 0 0 1-2.093-1.276 2.238 2.238 0 0 1-.197-1.264 2.282 2.282 0 0 1 2.33-2.52ZM31 2.64c1.26 0 2.06 1 2.06 2.54S32.24 7.72 31 7.72a1.617 1.617 0 0 1-1.53-.82h-.07v2.37h-1V2.73h.94v.81h.07a1.738 1.738 0 0 1 1.59-.9Zm5.34 0c1.27 0 2.06 1 2.06 2.54s-.79 2.54-2.05 2.54a1.61 1.61 0 0 1-1.52-.82h-.08v2.37h-1V2.73h.94v.81h.07a1.753 1.753 0 0 1 1.56-.9Zm.26 8.27a.621.621 0 0 1 .557.893.625.625 0 0 1-.702.33.621.621 0 0 1 .125-1.223Zm-7.3 0a.621.621 0 0 1 .557.893.625.625 0 0 1-.702.33.621.621 0 0 1 .125-1.223Zm43 2.8A1.278 1.278 0 0 0 71 15h2.5a1.203 1.203 0 0 0-.587-1.119 1.214 1.214 0 0 0-.633-.171Zm-39.46 0c-.85 0-1.32.64-1.32 1.64 0 1 .47 1.65 1.32 1.65.85 0 1.34-.64 1.34-1.65 0-1.01-.53-1.59-1.36-1.59Zm-8-1.49h-1.31V17h1.24c1.37 0 2.17-.87 2.17-2.39a2.099 2.099 0 0 0-.2-1.177 2.096 2.096 0 0 0-1.97-1.163Zm36 3.41-1.21.09c-.68 0-1 .28-1 .73 0 .45.39.72.92.72a1.17 1.17 0 0 0 1.28-1.11ZM44 15.68l-1.21.09c-.68 0-1 .28-1 .73 0 .45.39.72.92.72A1.168 1.168 0 0 0 44 16.11ZM24.88 11.4a2.894 2.894 0 0 1 2.83 1.572c.261.515.362 1.096.29 1.668 0 2.08-1.13 3.28-3.09 3.28h-2.4V11.4Zm4.89 1.6v4.91h-1V13Zm21.58-1.6 2.09 5.1h.08l2.1-5.1h1.19v6.52h-1v-4.78h-.01l-1.93 4.67h-.77l-1.93-4.67h-.06v4.78h-1V11.4ZM47 11.09v6.83h-1v-6.83ZM37.07 13v4.91h-1V13Zm40.49-.08c.149.015.296.041.44.08v.92a2.427 2.427 0 0 0-.55-.06 1.174 1.174 0 0 0-1.079.541 1.165 1.165 0 0 0-.181.599v2.93h-1V13h.94v.75h.07a1.34 1.34 0 0 1 1.36-.82Zm-38.19-1.15V13h1.07v.77h-1.07v2.6c0 .52.21.75.7.75.123.011.247.011.37 0v.78a4.26 4.26 0 0 1-.53 0c-1.09 0-1.52-.37-1.52-1.32V13.8h-.79V13h.79v-1.23Zm29.17 0V13h1.07v.77h-1.07v2.6c0 .52.21.75.7.75.123.011.247.011.37 0v.78a4.26 4.26 0 0 1-.53 0c-1.09 0-1.52-.37-1.52-1.32V13.8h-.79V13h.79v-1.23Zm-8.73 1.15c1.25 0 1.95.61 1.95 1.64v3.36h-.94v-.69h-.08a1.678 1.678 0 0 1-1.49.77 1.493 1.493 0 0 1-1.424-.675 1.497 1.497 0 0 1-.236-.785c0-.88.67-1.39 1.85-1.47l1.34-.07v-.43c0-.53-.34-.84-1-.84-.66 0-.93.21-1 .56h-.94c.05-.83.85-1.37 1.97-1.37Zm-16.75 0c1.25 0 1.95.61 1.95 1.64v3.36h-.94v-.69H44a1.682 1.682 0 0 1-1.5.77 1.493 1.493 0 0 1-1.424-.675 1.497 1.497 0 0 1-.236-.785c0-.88.67-1.39 1.85-1.47L44 15v-.43c0-.53-.34-.84-1-.84-.66 0-.93.21-1 .56h-1c.14-.83.94-1.37 2.06-1.37Zm21.44 0c1.12 0 1.84.51 1.94 1.36h-.94c-.09-.36-.44-.6-1-.6s-1 .26-1 .66.25.49.79.62l.82.19c.94.22 1.39.62 1.39 1.34 0 .91-.87 1.53-2.06 1.53s-2-.53-2-1.39h1c.082.212.233.39.429.505.197.114.426.158.651.125.61 0 1-.28 1-.68s-.23-.51-.73-.63l-.87-.2c-.94-.22-1.38-.63-1.38-1.36 0-.73.84-1.47 1.96-1.47Zm7.79 0a2.158 2.158 0 0 1 2.21 2.47v.34H71a1.326 1.326 0 0 0 .62 1.253c.21.13.453.198.7.197a1.174 1.174 0 0 0 1.18-.61h.94A2 2 0 0 1 72.29 18a2.237 2.237 0 0 1-2.085-1.265A2.245 2.245 0 0 1 70 15.48a2.29 2.29 0 0 1 .205-1.263 2.28 2.28 0 0 1 2.085-1.297Zm-39.74 0a1.73 1.73 0 0 1 1.56.86h.07V13h.94v5c0 1.12-.89 1.83-2.29 1.83-1.24 0-2-.55-2.15-1.37h1c.08.37.49.6 1.15.6.82 0 1.3-.4 1.3-1.06v-1h-.07a1.665 1.665 0 0 1-.647.632 1.667 1.667 0 0 1-.883.198c-1.27 0-2.05-1-2.05-2.45 0-1.45.8-2.45 2.07-2.45Z" fill-rule="evenodd" />
|
||||
<path clip-path="url(#a)" d="M-5-4.2h88v29H-5Z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 200 80" height="32">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" height="32">
|
||||
<path d="M25.5 14.9c-6.9 1.7-13.4 6.9-15.4 12.4-.6 1.6-1.1 5.2-1.1 8.1 0 7.3 5.3 17.9 13 25.7 5.6 5.7 6 5.9 10.9 5.9H38l-6.3-6.4c-8-8.2-11.7-15-12.4-22.5-.7-7.6 2-13.2 7.9-16.2 3.8-2 5.3-2.1 16.3-1.6 7.8.3 14.6 1.3 19.4 2.7 4 1.1 7.6 1.8 7.9 1.5 2.3-2.2-12.4-7.5-26.4-9.5-8.4-1.1-14.4-1.2-18.9-.1z" />
|
||||
<path d="M38 27.2c-12.8 6.5-11.9 21.3 2.1 34.3 4.6 4.2 6.7 5.5 9.1 5.5h3.1l-5.5-5.8c-7.2-7.5-10.2-13.3-9.5-18.3.6-4.7 3.2-8.4 7.4-10.4 3.5-1.7 17.3-2 23.6-.6 2.9.7 3.7.6 3.7-.5 0-1.8-3.5-3.2-12.8-5-10.5-2-16-1.8-21.2.8zM95.2 41.3c-3.4 8.9-6.2 16.6-6.2 17 0 .4.9.7 1.9.7 1.4 0 2.4-1.3 3.7-4.5l1.7-4.5h14.5l1.4 4.2c1.1 3.1 2.1 4.4 3.6 4.6 1.2.2 2.2 0 2.2-.5 0-.4-2.8-8.1-6.2-17-8.3-21.6-8.3-21.6-16.6 0zm13.8 4.4c0 .2-2.5.3-5.6.3-4.1 0-5.5-.3-5.2-1.3.3-.6 1.5-4.3 2.8-8.1l2.3-6.9 2.8 7.8c1.6 4.4 2.9 8 2.9 8.2zM126.2 41.3c-3.4 8.9-6.2 16.5-6.2 16.9 0 .5.9.8 1.9.8 1.4 0 2.4-1.3 3.7-4.5l1.7-4.5h14.5l1.4 4.2c1.1 3.2 2.1 4.4 3.7 4.6 2.8.4 3 1.2-4.4-18.1-8-20.9-8-20.9-16.3.6zm13.8 4.4c0 .2-2.5.3-5.6.3-4.1 0-5.5-.3-5.2-1.3.3-.6 1.5-4.3 2.8-8.1l2.3-7 2.8 8c1.6 4.3 2.9 8 2.9 8.1zM160.3 27.1c-8.3 4.1-11.6 15.2-7.1 23.9 2.8 5.5 7.3 8.2 13.6 8.3 6.2.1 9.2-1 9.2-3.3 0-1.7-.4-1.8-3.1-.8-4.3 1.5-10.6.4-13.5-2.3-3-2.8-4.7-8.8-3.8-13.5 1.4-7.6 9.1-12.5 16.4-10.4 1.9.6 3.6 1 3.7 1 1.1 0 0-3.9-1.3-4.4-3.2-1.2-10-.5-14.1 1.5z" />
|
||||
<path d="M54.3 35.7c-2.8.5-7.2 4.9-7.9 8-1.1 4.1.6 9.1 4.6 14.1 5.5 7 9.5 9.2 16.5 9.2h5.7l-.4-14.1c-.3-13.4-.5-14.2-2.7-16-2.3-1.8-9.6-2.4-15.8-1.2z" />
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
12
src/applemusic/glue/router.ts
Normal file
12
src/applemusic/glue/router.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { offRoute, onRoute } from "../../common/router";
|
||||
import { RouteCallback } from "../../common/router";
|
||||
|
||||
const ALBUM_PATTERN = "[a-z]{2}/album/.+/.+";
|
||||
|
||||
export function onAlbumRoute(cb: RouteCallback) {
|
||||
onRoute(ALBUM_PATTERN, cb);
|
||||
}
|
||||
|
||||
export function offAlbumRoute(cb: RouteCallback) {
|
||||
offRoute(ALBUM_PATTERN, cb);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { offRoute, onRoute } from "../../common";
|
||||
import { RouteCallback } from "../../common/router";
|
||||
|
||||
const ALBUM_PATTERN = "/:country/album/:slug?/:id";
|
||||
|
||||
export function onAlbumRoute(cb: RouteCallback, once = false) {
|
||||
onRoute(ALBUM_PATTERN, cb, once);
|
||||
}
|
||||
|
||||
export function offAlbumRoute(cb: RouteCallback, once = false) {
|
||||
offRoute(ALBUM_PATTERN, cb, once);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { fromHTML, waitFor } from "../../common";
|
||||
import { observeSelector } from "../../common/dom";
|
||||
import { fromHTML } from "../../common/dom";
|
||||
import { ensureMenu } from "../../common/menu";
|
||||
|
||||
export type ButtonElement = Brand<HTMLElement, "button">;
|
||||
|
||||
let navEl: HTMLElement | null = null;
|
||||
type ButtonElement = Brand<HTMLElement, "applemusic-button">;
|
||||
|
||||
export function createButtonElement(text: string, icon: string): ButtonElement {
|
||||
return fromHTML<ButtonElement>(`
|
||||
<li class="ame-sidebar-button navigation-item navigation-item__personalized" role="listitem" data-ame>
|
||||
<a class="navigation-item__link" role="button" tabindex="0" data-ame>
|
||||
<li class="ame-sidebar-button navigation-item" data-ame>
|
||||
<a class="navigation-item__link" tabindex="0" data-ame>
|
||||
${icon}
|
||||
<span>${text}</span>
|
||||
</a>
|
||||
@@ -15,54 +15,26 @@ export function createButtonElement(text: string, icon: string): ButtonElement {
|
||||
`);
|
||||
}
|
||||
|
||||
export async function showButtonElement(buttonEl: ButtonElement, index: number) {
|
||||
if (!navEl) {
|
||||
navEl = await waitFor(".navigation__scrollable-container", "amp-chrome-player");
|
||||
if (!navEl) return;
|
||||
}
|
||||
export async function showSidebarButton(buttonEl: ButtonElement, index: number) {
|
||||
await observeSelector("amp-chrome-player"); // Wait for native menus to load.
|
||||
|
||||
let categoryEl = document.querySelector("#ame-sidebar");
|
||||
if (!categoryEl) {
|
||||
categoryEl = fromHTML(`
|
||||
<div class="navigation-items navigation-items--personalized" data-ame>
|
||||
const menuEl = ensureMenu("#ame-sidebar", () => {
|
||||
const refEl = document.querySelector(".navigation__scrollable-container");
|
||||
refEl?.appendChild(fromHTML(`
|
||||
<div class="navigation-items" data-ame>
|
||||
<div class="navigation-items__header" data-ame>
|
||||
<span>Ame</span>
|
||||
</div>
|
||||
<ul id="ame-sidebar" role="list" class="navigation-items__list" data-ame>
|
||||
<ul class="navigation-items__list" data-ame>
|
||||
<li id="ame-sidebar" style="display: none;"></li>
|
||||
</ul>
|
||||
</div>
|
||||
`);
|
||||
`));
|
||||
});
|
||||
|
||||
navEl.appendChild(categoryEl);
|
||||
categoryEl = document.querySelector<HTMLElement>("#ame-sidebar")!;
|
||||
menuEl.addMenuItem(buttonEl, index);
|
||||
}
|
||||
|
||||
const buttonEls = Array.from(categoryEl.querySelectorAll(".ame-sidebar-button")) as HTMLElement[];
|
||||
buttonEl.setAttribute("data-index", index.toString());
|
||||
|
||||
if (buttonEls.length === 0) {
|
||||
categoryEl.appendChild(buttonEl);
|
||||
return;
|
||||
}
|
||||
|
||||
let bestDist = Number.MAX_VALUE;
|
||||
let refEl = buttonEls[0];
|
||||
|
||||
for (const buttonEl of buttonEls) {
|
||||
const dist = Math.abs(Number(buttonEl.getAttribute("data-index")) - index);
|
||||
if (dist >= bestDist) continue;
|
||||
|
||||
bestDist = dist;
|
||||
refEl = buttonEl;
|
||||
}
|
||||
|
||||
if (index > Number(refEl.getAttribute("data-index"))) {
|
||||
refEl.after(buttonEl);
|
||||
} else {
|
||||
refEl.before(buttonEl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hideButtonElement(buttonEl: ButtonElement) {
|
||||
export async function hideSidebarButton(buttonEl: ButtonElement) {
|
||||
buttonEl.remove();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @namespace ame-applemusic
|
||||
// @name Ame (Apple Music)
|
||||
// @version 1.8.1
|
||||
// @version 1.8.2
|
||||
// @author SuperSaltyGamer
|
||||
// @run-at document-start
|
||||
// @match https://music.apple.com/*
|
||||
@@ -10,46 +10,22 @@
|
||||
// @grant GM.xmlHttpRequest
|
||||
// ==/UserScript==
|
||||
|
||||
import { observe, waitFor } from '../common';
|
||||
import { offAlbumRoute, onAlbumRoute } from './glue/routing';
|
||||
import { hideButtonElement, showButtonElement } from './glue/sidebar';
|
||||
import './modules/badges';
|
||||
import { checkCountriesButtonEl } from './modules/countries';
|
||||
import { searchCoversButtonEl } from './modules/covers';
|
||||
import { copyAuthButtonEl } from './modules/dev';
|
||||
import { checkQualitiesButtonEl } from './modules/qualities';
|
||||
import { showInfoButtonEl } from './modules/info';
|
||||
import './modules/qualities';
|
||||
import styles from './style.css?inline';
|
||||
import handsonStyles from "handsontable/dist/handsontable.full.min.css?inline";
|
||||
import { observeSelector } from "../common/dom";
|
||||
import "./modules/badges";
|
||||
import "./modules/storefronts";
|
||||
import "./modules/covers";
|
||||
import "./modules/dev";
|
||||
import "./modules/qualities";
|
||||
import "./modules/info";
|
||||
import "./modules/qualities";
|
||||
import styles from "./style.css?inline";
|
||||
|
||||
GM.addStyle(handsonStyles);
|
||||
GM.addStyle(styles);
|
||||
|
||||
// Add sidebar button for all pages.
|
||||
waitFor('nav', 'amp-chrome-player').then((navEl) => {
|
||||
if (!navEl) return;
|
||||
|
||||
showButtonElement(copyAuthButtonEl, 0);
|
||||
});
|
||||
|
||||
// Add sidebar buttons for album page.
|
||||
onAlbumRoute(async () => {
|
||||
showButtonElement(checkCountriesButtonEl, 100);
|
||||
showButtonElement(checkQualitiesButtonEl, 200);
|
||||
showButtonElement(searchCoversButtonEl, 300);
|
||||
showButtonElement(showInfoButtonEl, 400);
|
||||
});
|
||||
|
||||
// Remove sidebar buttons for non-album pages.
|
||||
offAlbumRoute(() => {
|
||||
hideButtonElement(searchCoversButtonEl);
|
||||
hideButtonElement(checkCountriesButtonEl);
|
||||
hideButtonElement(showInfoButtonEl);
|
||||
});
|
||||
|
||||
// Hide trial upsell modal.
|
||||
observe('iframe[src^="/includes/commerce/subscribe"]', () => {
|
||||
const backdropEl = document.querySelector<HTMLElement>('.backdrop');
|
||||
backdropEl?.click();
|
||||
observeSelector<HTMLDialogElement>("iframe[src^='/includes/commerce/subscribe']", { timeout: 0 })
|
||||
.then((dialogEl) => {
|
||||
dialogEl?.remove();
|
||||
});
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
import { fromHTML, waitFor } from "../../common";
|
||||
import { fromHTML, observeSelector } from "../../common/dom";
|
||||
import lossyBadge from "../assets/badges/lossy.svg?raw";
|
||||
import admBadge from "../assets/badges/adm.svg?raw";
|
||||
import atmosBadge from "../assets/badges/atmos.svg?raw";
|
||||
import hiresLosslessBadge from "../assets/badges/hires-lossless.svg?raw";
|
||||
import losslessBadge from "../assets/badges/lossless.svg?raw";
|
||||
import spatialBadge from "../assets/badges/spatial.svg?raw";
|
||||
import { onAlbumRoute } from "../glue/routing";
|
||||
import { getAlbum } from "../services";
|
||||
import { onAlbumRoute } from "../glue/router";
|
||||
import { getAlbum, getPageAlbumId } from "../services/album";
|
||||
|
||||
// Reliably get album data for displaying quality badges.
|
||||
onAlbumRoute(async () => {
|
||||
if (document.querySelector(".ame-album-badges-container")) return;
|
||||
|
||||
const albumId = location.pathname.split("/")[4];
|
||||
const album = await getAlbum(albumId);
|
||||
const album = await getAlbum(getPageAlbumId());
|
||||
if (!album) return;
|
||||
|
||||
const refEl = await waitFor(".headings__metadata-bottom", ".description");
|
||||
const refEl = await observeSelector(".headings__metadata-bottom");
|
||||
if (!refEl) return;
|
||||
|
||||
const audioTraits = album.attributes.audioTraits;
|
||||
if (album.attributes.isMasteredForItunes) audioTraits.push("adm");
|
||||
|
||||
const containerEl = fromHTML(`<p class="ame-album-badges-container"></p>`);
|
||||
const containerEl = fromHTML<HTMLParagraphElement>(`<p class="ame-album-badges-container"></p>`);
|
||||
|
||||
if (audioTraits.includes("lossy-stereo")) containerEl.innerHTML += lossyBadge;
|
||||
if (audioTraits.includes("lossless")) containerEl.innerHTML += losslessBadge;
|
||||
if (audioTraits.includes("hi-res-lossless")) containerEl.innerHTML += hiresLosslessBadge;
|
||||
if (audioTraits.includes("atmos")) containerEl.innerHTML += atmosBadge;
|
||||
if (audioTraits.includes("adm")) containerEl.innerHTML += admBadge;
|
||||
if (audioTraits.includes("spatial")) containerEl.innerHTML += spatialBadge;
|
||||
if (audioTraits.includes("lossy-stereo")) containerEl.insertAdjacentHTML("beforeend", lossyBadge);
|
||||
if (audioTraits.includes("lossless")) containerEl.insertAdjacentHTML("beforeend", losslessBadge);
|
||||
if (audioTraits.includes("hi-res-lossless")) containerEl.insertAdjacentHTML("beforeend", hiresLosslessBadge);
|
||||
if (audioTraits.includes("atmos")) containerEl.insertAdjacentHTML("beforeend", atmosBadge);
|
||||
if (audioTraits.includes("adm")) containerEl.insertAdjacentHTML("beforeend", admBadge);
|
||||
if (audioTraits.includes("spatial")) containerEl.insertAdjacentHTML("beforeend", spatialBadge);
|
||||
|
||||
refEl.after(containerEl);
|
||||
});
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { fromHTML, sleep, waitFor } from '../../common';
|
||||
import flagIcon from '../assets/icons/flag.svg?raw';
|
||||
import { offAlbumRoute, onAlbumRoute } from '../glue/routing';
|
||||
import { createButtonElement } from '../glue/sidebar';
|
||||
import { getAlbum, getStorefronts } from '../services';
|
||||
|
||||
const PREFERRED_STOREFRONTS = [ 'jp', 'kr', 'us', 'de', 'fr', 'gb', 'in', 'it', 'es', 'br', 'au', 'nz', 'cn', 'hk' ];
|
||||
PREFERRED_STOREFRONTS.reverse();
|
||||
|
||||
let globalJob: AbortController | null = null;
|
||||
|
||||
export const checkCountriesButtonEl = createButtonElement('Check Countries', flagIcon);
|
||||
|
||||
// Start checking countries when the sidebar button is clicked.
|
||||
checkCountriesButtonEl.addEventListener('click', async () => {
|
||||
const refEl = document.querySelector<HTMLElement>('.section');
|
||||
if (refEl) await checkCountries(refEl);
|
||||
});
|
||||
|
||||
// Start checking countries when the error page is shown.
|
||||
onAlbumRoute(async () => {
|
||||
const errorEl = await waitFor('.page-error');
|
||||
if (errorEl) checkCountries(errorEl);
|
||||
});
|
||||
|
||||
onAlbumRoute(() => {
|
||||
globalJob?.abort();
|
||||
globalJob = null;
|
||||
});
|
||||
|
||||
offAlbumRoute(() => {
|
||||
globalJob?.abort();
|
||||
globalJob = null;
|
||||
});
|
||||
|
||||
async function checkCountries(refEl: HTMLElement) {
|
||||
if (globalJob) return;
|
||||
const job = new AbortController();
|
||||
globalJob = job;
|
||||
|
||||
const albumId = location.pathname.split('/')[4];
|
||||
|
||||
const headerEl = fromHTML(`<div class="section ame-album-countries-header">Availability in the following storefronts:</div>`);
|
||||
const containerEl = fromHTML(`
|
||||
<div class="section ame-album-countries-container">
|
||||
<div class="ame-color-primary"></div>
|
||||
<div class="ame-color-secondary"></div>
|
||||
<div class="ame-color-tertiary"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const primaryContainerEl = containerEl.children[0] as HTMLElement;
|
||||
const secondaryContainerEl = containerEl.children[1] as HTMLElement;
|
||||
const tertiaryContainerEl = containerEl.children[2] as HTMLElement;
|
||||
|
||||
refEl.append(headerEl);
|
||||
refEl.append(containerEl);
|
||||
|
||||
let storefronts = await getStorefronts();
|
||||
|
||||
storefronts = storefronts
|
||||
.map(storefront => {
|
||||
storefront.attributes.name = storefront.attributes.name.split(', ').reverse().join(' ');
|
||||
return storefront;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return Math.max(PREFERRED_STOREFRONTS.indexOf(b.id), 0) - Math.max(PREFERRED_STOREFRONTS.indexOf(a.id), 0);
|
||||
});
|
||||
|
||||
for (const storefront of storefronts) {
|
||||
if (job.signal.aborted) break;
|
||||
|
||||
const album = await getAlbum(albumId, storefront.id);
|
||||
|
||||
// Album totally unavailable.
|
||||
if (!album) {
|
||||
tertiaryContainerEl.append(fromHTML(`
|
||||
<span data-storefront="${storefront.id}" title="Totally unavailable">${storefront.attributes.name}, </span>
|
||||
`));
|
||||
|
||||
await sleep(222);
|
||||
continue;
|
||||
}
|
||||
|
||||
const discCount = Math.max(...album.relationships.tracks.data.map(track => track.attributes.discNumber));
|
||||
const songTracks = album.relationships.tracks.data.filter(track => track.type === 'songs');
|
||||
const otherTracks = album.relationships.tracks.data.filter(track => track.type !== 'songs');
|
||||
const unavailableTracks = new Set();
|
||||
for (let i = 1; i <= album.attributes.trackCount - otherTracks.length; i++) unavailableTracks.add(i);
|
||||
|
||||
songTracks.forEach((track, i) => {
|
||||
if (!track.attributes.extendedAssetUrls) return;
|
||||
if (!track.attributes.playParams) return;
|
||||
unavailableTracks.delete(discCount > 1 ? i + 1 : track.attributes.trackNumber);
|
||||
});
|
||||
|
||||
// Album partially available.
|
||||
if (unavailableTracks.size) {
|
||||
if (discCount > 1) {
|
||||
secondaryContainerEl.append(fromHTML(`
|
||||
<span data-storefront="${storefront.id}"><a target="_blank" href="https://music.apple.com/${storefront.id}/album/${albumId}" title="Partially available, missing:\n${unavailableTracks.size} tracks">${storefront.attributes.name}</a>, </span>
|
||||
`));
|
||||
} else {
|
||||
secondaryContainerEl.append(fromHTML(`
|
||||
<span data-storefront="${storefront.id}"><a target="_blank" href="https://music.apple.com/${storefront.id}/album/${albumId}" title="Partially available, missing:\n${Array.from(unavailableTracks).join(', ')}">${storefront.attributes.name}</a>, </span>
|
||||
`));
|
||||
}
|
||||
|
||||
await sleep(222);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Album fully available.
|
||||
primaryContainerEl.append(fromHTML(`
|
||||
<span data-storefront="${storefront.id}"><a target="_blank" href="https://music.apple.com/${storefront.id}/album/${albumId}" title="Fully available">${storefront.attributes.name}</a>, </span>
|
||||
`));
|
||||
|
||||
await sleep(222);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,37 @@
|
||||
import paletteIcon from "../assets/icons/palette.svg?raw";
|
||||
import { createButtonElement } from "../glue/sidebar";
|
||||
import { offAlbumRoute, onAlbumRoute } from "../glue/router";
|
||||
import { createButtonElement, hideSidebarButton, showSidebarButton } from "../glue/sidebar";
|
||||
|
||||
export const searchCoversButtonEl = createButtonElement("Search Covers", paletteIcon);
|
||||
|
||||
searchCoversButtonEl.addEventListener("click", () => {
|
||||
const titleEl = document.querySelector<HTMLElement>("h1.headings__title");
|
||||
const titleEl = document.querySelector<HTMLElement>(".headings__title");
|
||||
if (!titleEl) return;
|
||||
|
||||
const artistEls = Array.from(document.querySelectorAll<HTMLElement>(".headings__subtitles > a"));
|
||||
const artist = artistEls.map(el => el.innerText).join(" ");
|
||||
const album = titleEl.innerText.replace(" - Single", "").replace(" - EP", "");
|
||||
const artistEl = document.querySelector<HTMLElement>(".headings__subtitles > a");
|
||||
if (!artistEl) return;
|
||||
|
||||
const artist = artistEl.innerText.trim();
|
||||
const album = titleEl.innerText.trim().replace(/ - Single$/i, "").replace(/ - EP$/i, "");
|
||||
|
||||
open(`https://covers.musichoarders.xyz?artist=${encodeURIComponent(artist)}&album=${encodeURIComponent(album)}`, "_blank");
|
||||
});
|
||||
|
||||
onAlbumRoute(() => {
|
||||
showSidebarButton(searchCoversButtonEl, 300);
|
||||
});
|
||||
|
||||
offAlbumRoute(() => {
|
||||
hideSidebarButton(searchCoversButtonEl);
|
||||
});
|
||||
|
||||
// Replace release cover image with full sized variant on right click.
|
||||
addEventListener("mousedown", async e => {
|
||||
if (e.button !== 2) return;
|
||||
|
||||
const imgEl = e.target as HTMLImageElement;
|
||||
if (!imgEl.matches(".artwork-component__image:not(.ame-fullsized)")) return;
|
||||
imgEl.classList.add("ame-fullsized");
|
||||
if (!imgEl.matches(".artwork-component__image:not(.ame-full-sized)")) return;
|
||||
imgEl.classList.add("ame-full-sized");
|
||||
|
||||
const refSrcEl = document.querySelector<HTMLSourceElement>(".artwork__radiosity source");
|
||||
if (!refSrcEl) return;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import shieldIcon from "../assets/icons/shield.svg?raw";
|
||||
import { createButtonElement } from "../glue/sidebar";
|
||||
import { getAuthToken } from "../services";
|
||||
import { createButtonElement, showSidebarButton } from "../glue/sidebar";
|
||||
import { getAuthToken } from "../services/auth";
|
||||
|
||||
export const copyAuthButtonEl = createButtonElement("Copy Authorization", shieldIcon);
|
||||
const copyAuthButtonEl = createButtonElement("Copy Authorization", shieldIcon);
|
||||
|
||||
copyAuthButtonEl.addEventListener("click", async () => {
|
||||
GM.setClipboard(await getAuthToken());
|
||||
});
|
||||
|
||||
showSidebarButton(copyAuthButtonEl, 0);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Handsontable from "handsontable/base";
|
||||
import { registerPlugin, AutoColumnSize, ManualColumnMove, CopyPaste, DragToScroll } from "handsontable/plugins";
|
||||
import infoIcon from "../assets/icons/info.svg?raw";
|
||||
import { offAlbumRoute, onAlbumRoute } from "../glue/routing";
|
||||
import { createButtonElement } from "../glue/sidebar";
|
||||
import { getAccountStorefront, getAlbum, getStorefronts } from "../services";
|
||||
import { fetchCors, fromHTML } from "../../common";
|
||||
import { offAlbumRoute, onAlbumRoute } from "../glue/router";
|
||||
import { createButtonElement, hideSidebarButton, showSidebarButton } from "../glue/sidebar";
|
||||
import { getAlbum } from "../services/album";
|
||||
import { getAccountStorefront, getStorefronts } from "../services/storefront";
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { Album, Resource } from "../types";
|
||||
import { ripLyrics } from "./lyrics";
|
||||
import { fromHTML } from "../../common/dom";
|
||||
|
||||
registerPlugin(AutoColumnSize);
|
||||
registerPlugin(ManualColumnMove);
|
||||
@@ -85,10 +87,12 @@ let isVisible = false;
|
||||
let dockEl: HTMLElement | null = null;
|
||||
|
||||
onAlbumRoute(async () => {
|
||||
showSidebarButton(showInfoButtonEl, 400);
|
||||
hideDock();
|
||||
});
|
||||
|
||||
offAlbumRoute(() => {
|
||||
hideSidebarButton(showInfoButtonEl);
|
||||
hideDock();
|
||||
});
|
||||
|
||||
@@ -106,7 +110,7 @@ async function showDock() {
|
||||
<div id="ame-dock-title">Album Info</div>
|
||||
<div id="ame-dock-control">
|
||||
<select id="ame-dock-control-storefront">
|
||||
${storefronts.map(storefront => `<option value="${storefront.id}" ${storefront.id === activeStorefront ? "selected" : ""}>${storefront.attributes.name}</option>`).join("")}
|
||||
${storefronts.map(storefront => `<option value="${storefront.id}" ?selected="${storefront.id === activeStorefront}">${storefront.attributes.name}</option>`).join("")}
|
||||
</select>
|
||||
<button id="ame-dock-control-isrc2mb">ISRC2MB</button>
|
||||
<button id="ame-dock-control-lyrics">LYRICS (${getAccountStorefront()?.toUpperCase() || "N/A"})</button>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import JSZip from "jszip";
|
||||
import xmlFormatter from "xml-formatter";
|
||||
import { downloadFile, sleep } from "../../common";
|
||||
import { downloadFile, sleep } from "../../common/misc";
|
||||
import { getAlbum } from "../services/album";
|
||||
import { getLyrics } from "../services/lyrics";
|
||||
import { Lyrics, Track } from "../types";
|
||||
import { formatPath } from "../../common/format";
|
||||
import { getAccountStorefront } from "../services";
|
||||
import { getAccountStorefront } from "../services/storefront";
|
||||
|
||||
export async function ripLyrics(albumId: string) {
|
||||
const accountStorefront = getAccountStorefront();
|
||||
@@ -43,7 +43,9 @@ export async function ripLyrics(albumId: string) {
|
||||
}
|
||||
|
||||
function zipLyrics(zip: JSZip, track: Track, lyrics: Lyrics, syllable: boolean) {
|
||||
const filename = formatPath(`${track.discNumber}-${track.trackNumber.toString().padStart(2, "0")}. ${track.name.slice(0, 120)}`);
|
||||
const filename = formatPath(
|
||||
`${track.discNumber}-${track.trackNumber.toString().padStart(2, "0")}. ${track.name.slice(0, 120)}`
|
||||
);
|
||||
|
||||
// Save raw lyrics.
|
||||
if (syllable && lyrics.ttml.includes("<span begin=")) {
|
||||
@@ -60,7 +62,7 @@ function zipLyrics(zip: JSZip, track: Track, lyrics: Lyrics, syllable: boolean)
|
||||
for (const lineNode of Array.from(lyricsDoc.querySelectorAll("p")) as HTMLParagraphElement[]) {
|
||||
const timestamp = lineNode.getAttribute("begin");
|
||||
if (timestamp) {
|
||||
out += `[${convertTimestamp(timestamp)}] ${lineNode.textContent}\n`;
|
||||
out += `[${formatTimestamp(timestamp)}] ${lineNode.textContent}\n`;
|
||||
} else {
|
||||
out += `${lineNode.textContent}\n`;
|
||||
}
|
||||
@@ -70,7 +72,7 @@ function zipLyrics(zip: JSZip, track: Track, lyrics: Lyrics, syllable: boolean)
|
||||
}
|
||||
}
|
||||
|
||||
function convertTimestamp(timestamp: string): string {
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const parts = timestamp.split(/[:.]/g).reverse();
|
||||
const mm = (parts[2] ?? "").padStart(2, "0");
|
||||
const ss = (parts[1] ?? "").padStart(2, "0");
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
import { fetchCors, fromHTML, sleep } from '../../common';
|
||||
import hqIcon from '../assets/icons/hq.svg?raw';
|
||||
import { offAlbumRoute, onAlbumRoute } from '../glue/routing';
|
||||
import { createButtonElement } from '../glue/sidebar';
|
||||
import { getAlbum } from '../services';
|
||||
import { sleep } from "../../common/misc";
|
||||
import hqIcon from "../assets/icons/hq.svg?raw";
|
||||
import { offAlbumRoute, onAlbumRoute } from "../glue/router";
|
||||
import { createButtonElement, hideSidebarButton, showSidebarButton } from "../glue/sidebar";
|
||||
import { getAlbum, getPageAlbumId } from "../services/album";
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { fromHTML } from "../../common/dom";
|
||||
|
||||
interface Quality {
|
||||
'FIRST-SEGMENT-URI': string;
|
||||
'AUDIO-FORMAT-ID': string;
|
||||
'CHANNEL-USAGE'?: string;
|
||||
'CHANNEL-COUNT': string;
|
||||
'BIT-RATE'?: number;
|
||||
'SAMPLE-RATE': number;
|
||||
'BIT-DEPTH': number;
|
||||
'IS-ATMOS'?: boolean;
|
||||
'__ACTUAL__'?: true;
|
||||
"FIRST-SEGMENT-URI": string;
|
||||
"AUDIO-FORMAT-ID": string;
|
||||
"CHANNEL-USAGE"?: string;
|
||||
"CHANNEL-COUNT": string;
|
||||
"BIT-RATE"?: number;
|
||||
"SAMPLE-RATE": number;
|
||||
"BIT-DEPTH": number;
|
||||
"IS-ATMOS"?: boolean;
|
||||
"__ACTUAL__"?: true;
|
||||
}
|
||||
|
||||
const FORMAT_ORDER = [ 'ec+3', 'alac', 'aac ', 'aach' ];
|
||||
const CHANNEL_USAGE_ORDER = [ 'BINAURAL', 'DOWNMIX' ];
|
||||
const FORMAT_ORDER = [ "ec+3", "alac", "aac ", "aach" ];
|
||||
const CHANNEL_USAGE_ORDER = [ "BINAURAL", "DOWNMIX" ];
|
||||
|
||||
let globalJob: AbortController | null = null;
|
||||
|
||||
export const checkQualitiesButtonEl = createButtonElement('Check Qualities', hqIcon);
|
||||
export const checkQualitiesButtonEl = createButtonElement("Check Qualities", hqIcon);
|
||||
|
||||
checkQualitiesButtonEl.addEventListener('click', async () => {
|
||||
checkQualitiesButtonEl.addEventListener("click", async () => {
|
||||
if (globalJob) return;
|
||||
const job = new AbortController();
|
||||
globalJob = job;
|
||||
|
||||
const country = location.pathname.split('/')[1];
|
||||
const albumId = location.pathname.split('/')[4];
|
||||
|
||||
const album = await getAlbum(albumId, country);
|
||||
const album = await getAlbum(getPageAlbumId());
|
||||
if (!album) return;
|
||||
|
||||
const trackEls = Array.from(document.querySelectorAll<HTMLElement>('.songs-list-row__song-wrapper'));
|
||||
const trackEls = Array.from(document.querySelectorAll<HTMLElement>(".songs-list-row__song-wrapper"));
|
||||
|
||||
for (const track of album.relationships.tracks.data) {
|
||||
if (job.signal.aborted) break;
|
||||
if (track.type !== 'songs') continue;
|
||||
if (track.type !== "songs") continue;
|
||||
|
||||
const trackEl = trackEls.shift();
|
||||
if (!trackEl) continue;
|
||||
|
||||
trackEl.querySelector('.ame-track-quality')?.remove();
|
||||
|
||||
if (!track.attributes.extendedAssetUrls) {
|
||||
trackEl.append(fromHTML(`<span class="ame-track-quality ame-color-warning">[unavailable]</span>`));
|
||||
continue;
|
||||
@@ -56,17 +53,17 @@ checkQualitiesButtonEl.addEventListener('click', async () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifest = await (await fetchCors(manifestUrl)).text();
|
||||
const manifest = await fetchCors(manifestUrl).then((res) => res.text());
|
||||
await sleep(150);
|
||||
|
||||
let data: Record<string, Quality> | null = null;
|
||||
for (const line of manifest.split('\n')) {
|
||||
if (!line.startsWith('#EXT-X-SESSION-DATA:DATA-ID="com.apple.hls.audioAssetMetadata"')) continue;
|
||||
data = JSON.parse(atob(line.split('VALUE=')[1].slice(1, -1)));
|
||||
for (const line of manifest.split("\n")) {
|
||||
if (!line.startsWith("#EXT-X-SESSION-DATA:DATA-ID=\"com.apple.hls.audioAssetMetadata\"")) continue;
|
||||
data = JSON.parse(atob(line.split("VALUE=")[1].slice(1, -1)));
|
||||
break;
|
||||
}
|
||||
|
||||
if (!data) throw new Error('Could not find data from track manifest.');
|
||||
if (!data) throw new Error("Could not find data from track manifest.");
|
||||
|
||||
const qualities = Object.values(data);
|
||||
qualities.sort(sortQuality);
|
||||
@@ -75,56 +72,62 @@ checkQualitiesButtonEl.addEventListener('click', async () => {
|
||||
if (realInfo) qualities.push(realInfo);
|
||||
|
||||
qualities.sort(sortQuality);
|
||||
const displayQuality = qualities.find(quality => parseInt(quality['CHANNEL-COUNT']) <= 2)!;
|
||||
|
||||
trackEl.append(fromHTML(`<span class="ame-track-quality ame-color-tertiary" title="${qualities.map(formatQuality).join('\n')}">${formatQuality(displayQuality)}</span>`));
|
||||
// Only use tracks with 2 channels for the highest quality display.
|
||||
const displayQuality = qualities.find(quality => parseInt(quality["CHANNEL-COUNT"]) <= 2)!;
|
||||
|
||||
trackEl.append(fromHTML(`<span class="ame-track-quality ame-color-tertiary" title="${qualities.map(formatQuality).join("\n")}">${formatQuality(displayQuality)}</span>`));
|
||||
}
|
||||
});
|
||||
|
||||
onAlbumRoute(() => {
|
||||
showSidebarButton(checkQualitiesButtonEl, 200);
|
||||
globalJob?.abort();
|
||||
globalJob = null;
|
||||
});
|
||||
|
||||
offAlbumRoute(() => {
|
||||
hideSidebarButton(checkQualitiesButtonEl);
|
||||
globalJob?.abort();
|
||||
globalJob = null;
|
||||
});
|
||||
|
||||
function sortQuality(a: Quality, b: Quality): number {
|
||||
return FORMAT_ORDER.indexOf(a['AUDIO-FORMAT-ID']) - FORMAT_ORDER.indexOf(b['AUDIO-FORMAT-ID']) ||
|
||||
b['BIT-DEPTH'] - a['BIT-DEPTH'] ||
|
||||
b['SAMPLE-RATE'] - a['SAMPLE-RATE'] ||
|
||||
(b['BIT-RATE'] ?? NaN) - (a['BIT-RATE'] ?? NaN) ||
|
||||
CHANNEL_USAGE_ORDER.indexOf(a['CHANNEL-USAGE'] ?? '') - CHANNEL_USAGE_ORDER.indexOf(b['CHANNEL-USAGE'] ?? '') ||
|
||||
-Number(a['__ACTUAL__']);
|
||||
return FORMAT_ORDER.indexOf(a["AUDIO-FORMAT-ID"]) - FORMAT_ORDER.indexOf(b["AUDIO-FORMAT-ID"]) ||
|
||||
b["BIT-DEPTH"] - a["BIT-DEPTH"] ||
|
||||
b["SAMPLE-RATE"] - a["SAMPLE-RATE"] ||
|
||||
(b["BIT-RATE"] ?? NaN) - (a["BIT-RATE"] ?? NaN) ||
|
||||
CHANNEL_USAGE_ORDER.indexOf(a["CHANNEL-USAGE"] ?? "") - CHANNEL_USAGE_ORDER.indexOf(b["CHANNEL-USAGE"] ?? "") ||
|
||||
-Number(a["__ACTUAL__"]);
|
||||
}
|
||||
|
||||
function formatQuality(quality: Quality): string {
|
||||
const parts = [];
|
||||
|
||||
parts.push(quality['AUDIO-FORMAT-ID']);
|
||||
if (quality['CHANNEL-COUNT']) parts.push(`${quality['CHANNEL-COUNT']}ch`);
|
||||
if (quality['BIT-RATE']) parts.push(`${Math.floor(Number(quality['BIT-RATE']) / 1000)}kbps`);
|
||||
if (quality['BIT-DEPTH']) parts.push(`${quality['BIT-DEPTH']}bit`);
|
||||
if (quality['SAMPLE-RATE']) parts.push(`${Math.floor(Number(quality['SAMPLE-RATE']) / 1000)}kHz`);
|
||||
if (quality['CHANNEL-USAGE']) parts.push(quality['CHANNEL-USAGE'].toLowerCase());
|
||||
if (quality['IS-ATMOS']) parts.push('atmos');
|
||||
if (quality['__ACTUAL__']) parts.push('[ACTUAL]');
|
||||
parts.push(quality["AUDIO-FORMAT-ID"]);
|
||||
if (quality["CHANNEL-COUNT"]) parts.push(`${quality["CHANNEL-COUNT"]}ch`);
|
||||
if (quality["BIT-RATE"]) parts.push(`${Math.floor(Number(quality["BIT-RATE"]) / 1000)}kbps`);
|
||||
if (quality["BIT-DEPTH"]) parts.push(`${quality["BIT-DEPTH"]}bit`);
|
||||
if (quality["SAMPLE-RATE"]) parts.push(`${Math.floor(Number(quality["SAMPLE-RATE"]) / 1000)}kHz`);
|
||||
if (quality["CHANNEL-USAGE"]) parts.push(quality["CHANNEL-USAGE"].toLowerCase());
|
||||
if (quality["IS-ATMOS"]) parts.push("atmos");
|
||||
if (quality["__ACTUAL__"]) parts.push("[ACTUAL]");
|
||||
|
||||
return parts.join(' ');
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
async function fetchRealAlacQuality(manifestUrl: string, qualities: Quality[]): Promise<Quality | null> {
|
||||
const baseUrl = manifestUrl.split('/').slice(0, -1).join('/');
|
||||
const firstSegmentUrl = qualities.filter(quality => quality['AUDIO-FORMAT-ID'] === 'alac' && parseInt(quality['CHANNEL-COUNT']) <= 2).at(-1)?.['FIRST-SEGMENT-URI'];
|
||||
const baseUrl = manifestUrl.split("/").slice(0, -1).join("/");
|
||||
|
||||
// Only fetch the lowest quality 2 channel ALAC track.
|
||||
const firstSegmentUrl = qualities.filter(quality => quality["AUDIO-FORMAT-ID"] === "alac" && parseInt(quality["CHANNEL-COUNT"]) <= 2).at(-1)?.["FIRST-SEGMENT-URI"];
|
||||
if (!firstSegmentUrl) return null;
|
||||
|
||||
const res = await (await fetchCors(`${baseUrl}/${firstSegmentUrl}`, {
|
||||
const res = await fetchCors(`${baseUrl}/${firstSegmentUrl}`, {
|
||||
headers: {
|
||||
'Range': 'bytes=0-16384'
|
||||
"Range": "bytes=0-16384"
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
const view = new DataView(await res.arrayBuffer());
|
||||
|
||||
@@ -154,12 +157,12 @@ async function fetchRealAlacQuality(manifestUrl: string, qualities: Quality[]):
|
||||
pos += 8 + 28; // Move inside atom.
|
||||
case 0x61_6C_61_63: // alac
|
||||
return {
|
||||
'FIRST-SEGMENT-URI': firstSegmentUrl,
|
||||
'AUDIO-FORMAT-ID': 'alac',
|
||||
'CHANNEL-COUNT': view.getUint8(pos + 8 + 13).toString(),
|
||||
'BIT-DEPTH': view.getUint8(pos + 8 + 9),
|
||||
'SAMPLE-RATE': view.getInt32(pos + 8 + 24),
|
||||
'__ACTUAL__': true
|
||||
"FIRST-SEGMENT-URI": firstSegmentUrl,
|
||||
"AUDIO-FORMAT-ID": "alac",
|
||||
"CHANNEL-COUNT": view.getUint8(pos + 8 + 13).toString(),
|
||||
"BIT-DEPTH": view.getUint8(pos + 8 + 9),
|
||||
"SAMPLE-RATE": view.getInt32(pos + 8 + 24),
|
||||
"__ACTUAL__": true
|
||||
};
|
||||
default:
|
||||
pos += atomSize;
|
||||
|
||||
114
src/applemusic/modules/storefronts.ts
Normal file
114
src/applemusic/modules/storefronts.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { sleep } from "../../common/misc";
|
||||
import { fromHTML, observeSelector } from "../../common/dom";
|
||||
import flagIcon from "../assets/icons/flag.svg?raw";
|
||||
import { offAlbumRoute, onAlbumRoute } from "../glue/router";
|
||||
import { createButtonElement, hideSidebarButton, showSidebarButton } from "../glue/sidebar";
|
||||
import { getAlbum, getPageAlbumId } from "../services/album";
|
||||
import { getStorefronts } from "../services/storefront";
|
||||
|
||||
const PREFERRED_STOREFRONTS = [ "jp", "kr", "us", "nz", "au", "de", "fr", "gb", "in", "it", "es", "br", "cn", "hk" ];
|
||||
PREFERRED_STOREFRONTS.reverse();
|
||||
|
||||
let globalJob: AbortController | null = null;
|
||||
|
||||
const checkStorefrontsButtonEl = createButtonElement("Check Storefronts", flagIcon);
|
||||
|
||||
checkStorefrontsButtonEl.addEventListener("click", async () => {
|
||||
// Only allow when album page loaded successfully.
|
||||
const refEl = document.querySelector<HTMLElement>(".section");
|
||||
if (refEl) await checkCountries(refEl);
|
||||
});
|
||||
|
||||
onAlbumRoute(async () => {
|
||||
const errorEl = await observeSelector(".page-error");
|
||||
if (errorEl) await checkCountries(errorEl);
|
||||
});
|
||||
|
||||
onAlbumRoute(() => {
|
||||
showSidebarButton(checkStorefrontsButtonEl, 100);
|
||||
globalJob?.abort();
|
||||
globalJob = null;
|
||||
});
|
||||
|
||||
offAlbumRoute(() => {
|
||||
hideSidebarButton(checkStorefrontsButtonEl);
|
||||
globalJob?.abort();
|
||||
globalJob = null;
|
||||
});
|
||||
|
||||
async function checkCountries(refEl: HTMLElement) {
|
||||
if (globalJob) return;
|
||||
const job = new AbortController();
|
||||
globalJob = job;
|
||||
|
||||
const albumId = getPageAlbumId();
|
||||
|
||||
const headerEl = fromHTML(`<div class="section ame-album-storefronts-header">Availability in the following storefronts:</div>`);
|
||||
const containerEl = fromHTML(`
|
||||
<div class="section ame-album-storefronts-container">
|
||||
<div class="ame-color-primary"></div>
|
||||
<div class="ame-color-secondary"></div>
|
||||
<div class="ame-color-tertiary"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const primaryContainerEl = containerEl.children[0] as HTMLElement;
|
||||
const secondaryContainerEl = containerEl.children[1] as HTMLElement;
|
||||
const tertiaryContainerEl = containerEl.children[2] as HTMLElement;
|
||||
|
||||
refEl.append(headerEl);
|
||||
refEl.append(containerEl);
|
||||
|
||||
let storefronts = await getStorefronts();
|
||||
|
||||
storefronts = storefronts
|
||||
.map(storefront => {
|
||||
storefront.attributes.name = storefront.attributes.name.split(", ").slice(0, 1).join(" ");
|
||||
return storefront;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return Math.max(PREFERRED_STOREFRONTS.indexOf(b.id), 0) - Math.max(PREFERRED_STOREFRONTS.indexOf(a.id), 0);
|
||||
});
|
||||
|
||||
for (const storefront of storefronts) {
|
||||
if (job.signal.aborted) break;
|
||||
|
||||
const album = await getAlbum(albumId, storefront.id);
|
||||
|
||||
// Album totally unavailable.
|
||||
if (!album) {
|
||||
tertiaryContainerEl.append(fromHTML(`<span data-storefront="${storefront.id}" title="Totally unavailable">${storefront.attributes.name}, </span>`));
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
const discCount = Math.max(...album.relationships.tracks.data.map(track => track.attributes.discNumber));
|
||||
const songTracks = album.relationships.tracks.data.filter(track => track.type === "songs");
|
||||
const otherTracks = album.relationships.tracks.data.filter(track => track.type !== "songs");
|
||||
const unavailableTracks = new Set();
|
||||
for (let i = 1; i <= album.attributes.trackCount - otherTracks.length; i++) unavailableTracks.add(i);
|
||||
|
||||
songTracks.forEach((track, i) => {
|
||||
if (!track.attributes.extendedAssetUrls) return;
|
||||
if (!track.attributes.playParams) return;
|
||||
unavailableTracks.delete(discCount > 1 ? i + 1 : track.attributes.trackNumber);
|
||||
});
|
||||
|
||||
// Album partially available.
|
||||
if (unavailableTracks.size) {
|
||||
const missingText = discCount > 1
|
||||
? `${unavailableTracks.size} tracks`
|
||||
: Array.from(unavailableTracks).join(", ");
|
||||
|
||||
secondaryContainerEl.append(fromHTML(`<span data-storefront="${storefront.id}"><a target="_blank" href="https://music.apple.com/${storefront.id}/album/${albumId}" title="Partially available, missing:\n${missingText}">${storefront.attributes.name}</a>, </span>`));
|
||||
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Album fully available.
|
||||
primaryContainerEl.append(fromHTML(`<span data-storefront="${storefront.id}"><a target="_blank" href="https://music.apple.com/${storefront.id}/album/${albumId}" title="Fully available">${storefront.attributes.name}</a>, </span>`));
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import { fetchCors } from '../../common';
|
||||
import { Album, ApiResponse, Resource } from '../types';
|
||||
import { getAuthToken } from './auth';
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { Album, ApiResponse, Resource } from "../types";
|
||||
import { getAuthToken } from "./auth";
|
||||
import { getPageStorefront } from "./storefront";
|
||||
|
||||
export function getPageAlbumId(): string {
|
||||
return location.pathname.split("/")[4];
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string, storefront?: string): Promise<Resource<Album> | null> {
|
||||
storefront ??= location.pathname.split('/')[1];
|
||||
storefront ??= getPageStorefront();
|
||||
|
||||
const res = await fetchCors(`https://amp-api.music.apple.com/v1/catalog/${storefront}/albums/${id}?extend=extendedAssetUrls`, {
|
||||
headers: {
|
||||
'Origin': 'https://music.apple.com',
|
||||
'Referer': 'https://music.apple.com/',
|
||||
'Authorization': `Bearer ${await getAuthToken()}`
|
||||
"Origin": "https://music.apple.com",
|
||||
"Referer": "https://music.apple.com/",
|
||||
"Authorization": `Bearer ${await getAuthToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 404) return null;
|
||||
|
||||
const albums = await res.json<ApiResponse<Album>>();
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { fetchCors, readCookies } from '../../common';
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { readCookies } from "../../common/misc";
|
||||
|
||||
let cachedAuthToken = '';
|
||||
let cachedAuthToken = "";
|
||||
|
||||
export async function getAuthToken(): Promise<string> {
|
||||
if (cachedAuthToken) return cachedAuthToken;
|
||||
|
||||
const scriptEl = document.querySelector<HTMLScriptElement>('script[type="module"]');
|
||||
if (!scriptEl) throw new Error('Failed to find script with auth token.');
|
||||
const scriptEl = document.querySelector<HTMLScriptElement>("script[type='module']");
|
||||
if (!scriptEl) throw new Error("Failed to find script with auth token.");
|
||||
|
||||
const res = await fetchCors(scriptEl.src);
|
||||
const body = await res.text();
|
||||
|
||||
const match = body.match(/(?<=")eyJhbGciOiJ.+?(?=")/);
|
||||
if (!match) throw new Error('Failed to find auth token from script.');
|
||||
if (!match) throw new Error("Failed to find auth token from script.");
|
||||
|
||||
cachedAuthToken = match[0];
|
||||
return cachedAuthToken;
|
||||
@@ -22,8 +23,3 @@ export function getUserToken(): string | null {
|
||||
const cookies = readCookies();
|
||||
return cookies["music-user-token"] || null;
|
||||
}
|
||||
|
||||
export function getAccountStorefront(): string | null {
|
||||
const cookies = readCookies();
|
||||
return cookies["itua"] || null;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { fetchCors } from '../../common';
|
||||
import { ApiResponse, Resource, Storefront } from '../types';
|
||||
import { getAuthToken } from './auth';
|
||||
|
||||
export * from './album';
|
||||
export * from './auth';
|
||||
|
||||
export async function getStorefronts(): Promise<Resource<Storefront>[]> {
|
||||
const res = await fetchCors('https://api.music.apple.com/v1/storefronts', {
|
||||
headers: {
|
||||
'Origin': 'https://music.apple.com',
|
||||
'Referer': 'https://music.apple.com/',
|
||||
'Authorization': `Bearer ${await getAuthToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
const body = await res.json<ApiResponse<Storefront>>();
|
||||
return body.data;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { fetchCors } from "../../common";
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { ApiResponse, Lyrics, Resource } from "../types";
|
||||
import { getAuthToken } from "./auth";
|
||||
import { getPageStorefront } from "./storefront";
|
||||
|
||||
export async function getLyrics(id: string, syllable: boolean, storefront?: string): Promise<Resource<Lyrics> | null> {
|
||||
storefront ??= location.pathname.split("/")[1];
|
||||
storefront ??= getPageStorefront();
|
||||
|
||||
const res = await fetchCors(`https://amp-api.music.apple.com/v1/catalog/${storefront}/songs/${id}/${syllable ? "syllable-lyrics" : "lyrics"}`, {
|
||||
headers: {
|
||||
@@ -12,7 +13,6 @@ export async function getLyrics(id: string, syllable: boolean, storefront?: stri
|
||||
"Authorization": `Bearer ${await getAuthToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 404) return null;
|
||||
|
||||
const lyrics = await res.json<ApiResponse<Lyrics>>();
|
||||
|
||||
25
src/applemusic/services/storefront.ts
Normal file
25
src/applemusic/services/storefront.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { readCookies } from "../../common/misc";
|
||||
import { ApiResponse, Resource, Storefront } from "../types";
|
||||
import { getAuthToken } from "./auth";
|
||||
|
||||
export function getPageStorefront(): string {
|
||||
return location.pathname.split("/")[1];
|
||||
}
|
||||
|
||||
export function getAccountStorefront(): string | null {
|
||||
const cookies = readCookies();
|
||||
return cookies["itua"] || null;
|
||||
}
|
||||
|
||||
export async function getStorefronts(): Promise<Resource<Storefront>[]> {
|
||||
const res = await fetchCors("https://api.music.apple.com/v1/storefronts", {
|
||||
headers: {
|
||||
"Origin": "https://music.apple.com",
|
||||
"Referer": "https://music.apple.com/",
|
||||
"Authorization": `Bearer ${await getAuthToken()}`
|
||||
}
|
||||
});
|
||||
const body = await res.json<ApiResponse<Storefront>>();
|
||||
return body.data;
|
||||
}
|
||||
@@ -28,16 +28,35 @@
|
||||
|
||||
/* Hide Open in Music button in the sidebar. */
|
||||
|
||||
.navigation__scrollable-container + .navigation__native-cta {
|
||||
.navigation__native-cta {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Styles for sidebar buttons. */
|
||||
/* Fix artwork breaking when overflown metadata exceeds its height. */
|
||||
|
||||
.navigation-items[data-ame] {
|
||||
padding-top: 9px;
|
||||
div[slot="artwork"] {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
/* Add more room for error messages and button text on error pages. */
|
||||
|
||||
.page-error {
|
||||
width: 100% !important;
|
||||
max-width: 960px !important;
|
||||
}
|
||||
|
||||
/* Make release metadata selectable. */
|
||||
|
||||
.headings__title, /* Release title */
|
||||
.headings__subtitles *, /* Release artists */
|
||||
.description * { /* Release description */
|
||||
user-select: text;
|
||||
-moz-user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Styles for sidebar buttons. */
|
||||
|
||||
.navigation-items__header[data-ame] {
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
@@ -61,8 +80,8 @@
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
--linkHoverTextDecoration: none;
|
||||
border-radius: 6px;
|
||||
--linkHoverTextDecoration: none;
|
||||
}
|
||||
|
||||
.navigation-item__link[data-ame] {
|
||||
@@ -92,123 +111,40 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Make sidebar buttons stick to the top and stack on top of each other. */
|
||||
|
||||
nav {
|
||||
padding-bottom: .5em;
|
||||
grid-template-rows: min-content min-content minmax(0, min-content) min-content min-content min-content min-content min-content min-content min-content min-content !important;
|
||||
}
|
||||
|
||||
.navigation__scrollable-container {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.navigation__native-cta {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Add focus styles to sidebar buttons. */
|
||||
|
||||
.native-cta {
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.native-cta__button svg,
|
||||
.native-cta__button .native-cta__label {
|
||||
transition: 50ms linear color, 50ms linear fill;
|
||||
}
|
||||
|
||||
.native-cta__button:active svg,
|
||||
.native-cta__button:active .native-cta__label {
|
||||
color: white !important;
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
/* Make more room for error messages and button text. */
|
||||
|
||||
.page-error {
|
||||
width: 100% !important;
|
||||
max-width: 900px !important;
|
||||
}
|
||||
|
||||
.page-error__title + .button button {
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
/* Fix artwork breaking when overflown metadata exceeds its height. */
|
||||
|
||||
div[slot="artwork"] {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
/* Make album metadata selectable */
|
||||
|
||||
.container-detail-header,
|
||||
.headings,
|
||||
.headings *,
|
||||
.headings__subtitles *,
|
||||
dialog *,
|
||||
.section-content,
|
||||
.tracklist-footer,
|
||||
.tracklist-footer * {
|
||||
user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
}
|
||||
|
||||
.headings__metadata-bottom {
|
||||
user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
.songs-list-row__song-name {
|
||||
width: min-content;
|
||||
user-select: all !important;
|
||||
-moz-user-select: all !important;
|
||||
-webkit-user-select: all !important;
|
||||
}
|
||||
|
||||
/* Styles for album quality badges. */
|
||||
|
||||
.headings__metadata:last-child {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
/* Styles for release quality badges. */
|
||||
|
||||
.ame-album-badges-container > svg {
|
||||
margin-right: 1em;
|
||||
margin-top: 1em;
|
||||
fill: #999999;
|
||||
fill: var(--systemSecondary);
|
||||
}
|
||||
|
||||
/* Styles for album country availabilities. */
|
||||
/* Styles for release country availabilities. */
|
||||
|
||||
.ame-album-countries-header {
|
||||
.ame-album-storefronts-header {
|
||||
margin: 0 var(--bodyGutter);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.ame-album-countries-container {
|
||||
.ame-album-storefronts-container {
|
||||
display: flex;
|
||||
margin: 0 var(--bodyGutter);
|
||||
margin-bottom: var(--bodyGutter);
|
||||
padding: 1em 0;
|
||||
padding-top: 1em;
|
||||
line-height: 2.2;
|
||||
text-align: justify;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ame-album-storefronts-container > div:not(:last-child):not(:empty) {
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.ame-album-storefronts-container * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.ame-album-countries-container * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.ame-album-countries-container div:not(:empty) {
|
||||
padding: .5em 0;
|
||||
}
|
||||
|
||||
/* Styles for docked info table. */
|
||||
/* Styles for info table. */
|
||||
|
||||
.ame-table-band {
|
||||
background-color: #eee !important;
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface Track {
|
||||
composerName: string;
|
||||
audioLocale: string;
|
||||
releaseDate: string;
|
||||
playParams: object | null;
|
||||
playParams?: object;
|
||||
hasLyrics: boolean;
|
||||
durationInMillis: number;
|
||||
genreNames: string[];
|
||||
|
||||
53
src/common/dom.ts
Normal file
53
src/common/dom.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export function fromHTML<T extends HTMLElement>(value: string): T {
|
||||
const el = document.createElement("template");
|
||||
el.innerHTML = value;
|
||||
return el.content.firstElementChild as T;
|
||||
}
|
||||
|
||||
interface WaitForOptions {
|
||||
waitSelector?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export function observeSelector<T extends HTMLElement>(selector: string, options?: WaitForOptions): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
const waitSelector = options?.waitSelector;
|
||||
const timeout = options?.timeout ?? 3000;
|
||||
|
||||
if (timeout !== 0) {
|
||||
const el = document.querySelector<T>(selector);
|
||||
if (el) {
|
||||
resolve(el);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const disposeTimeout = setTimeout(() => {
|
||||
if (timeout === 0) return;
|
||||
observer.disconnect();
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
|
||||
const observer = new MutationObserver(mutations => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of Array.from(mutation.addedNodes)) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
if (!node.matches(waitSelector ?? selector)) continue;
|
||||
|
||||
if (timeout !== 0) {
|
||||
observer.disconnect();
|
||||
clearTimeout(disposeTimeout);
|
||||
}
|
||||
|
||||
resolve(waitSelector ? document.querySelector<T>(selector) : node as T);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,20 +9,20 @@ export function fetchCors(url: string, init?: RequestInit): Promise<Response> {
|
||||
}
|
||||
|
||||
GM.xmlHttpRequest({
|
||||
method: init?.method ?? 'GET' as any,
|
||||
url: url,
|
||||
headers: Object.fromEntries(new Headers(init?.headers) as any),
|
||||
responseType: 'blob',
|
||||
url,
|
||||
method: init?.method ?? ("GET" as any),
|
||||
headers: Object.fromEntries(new Headers(init?.headers)),
|
||||
responseType: "blob",
|
||||
onload(res) {
|
||||
if (res.status !== 404 && (res.status < 200 || res.status > 299)) {
|
||||
if ((res.status < 200 || res.status > 299) && res.status !== 404) {
|
||||
reject(new Error(`Fetching "${url}" responded with an erroneous status code.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = res.responseHeaders
|
||||
.split('\r\n')
|
||||
.split("\r\n")
|
||||
.slice(0, -1)
|
||||
.map(line => line.split(': '));
|
||||
.map(line => line.split(": "));
|
||||
|
||||
const fetchRes = new Response(res.response, {
|
||||
headers: Object.fromEntries(headers),
|
||||
@@ -30,16 +30,16 @@ export function fetchCors(url: string, init?: RequestInit): Promise<Response> {
|
||||
statusText: res.statusText
|
||||
});
|
||||
|
||||
Object.defineProperty(fetchRes, 'url', { value: url });
|
||||
Object.defineProperty(fetchRes, "url", { value: url });
|
||||
|
||||
cache.set(url, fetchRes.clone());
|
||||
resolve(fetchRes);
|
||||
},
|
||||
onerror() {
|
||||
reject(new TypeError('Network request errored.'));
|
||||
reject(new Error("Network request errored."));
|
||||
},
|
||||
ontimeout() {
|
||||
reject(new TypeError('Network request timed out.'));
|
||||
reject(new Error("Network request timed out."));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
export * from './fetch';
|
||||
export * from './router';
|
||||
|
||||
export function readCookies(): Record<string, string> {
|
||||
return Object.fromEntries(document.cookie.split("; ").map(cookie => cookie.split("=", 2)));
|
||||
}
|
||||
|
||||
export function sleep(delay: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadFile(data: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(data);
|
||||
const el = document.createElement('a');
|
||||
el.style.display = 'none';
|
||||
el.download = filename;
|
||||
el.href = url;
|
||||
document.body.appendChild(el);
|
||||
el.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function fromHTML<T extends HTMLElement>(html: string): T {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.firstElementChild as T;
|
||||
}
|
||||
|
||||
export function waitFor<T extends HTMLElement>(selector: string, waitSelector?: string, timeout: number = 5000, refEl: HTMLElement | ShadowRoot | Document = document): Promise<T | null> {
|
||||
return new Promise<T | null>((resolve) => {
|
||||
let disposeTimeout = 0;
|
||||
let disposeInterval = 0;
|
||||
|
||||
disposeTimeout = setTimeout(() => {
|
||||
clearInterval(disposeInterval);
|
||||
resolve(null);
|
||||
}, timeout) as any;
|
||||
|
||||
disposeInterval = setInterval(() => {
|
||||
let el = refEl.querySelector<T>(waitSelector ?? selector);
|
||||
if (!el) return;
|
||||
|
||||
if (waitSelector) {
|
||||
el = refEl.querySelector<T>(selector);
|
||||
if (!el) return;
|
||||
}
|
||||
|
||||
resolve(el);
|
||||
clearTimeout(disposeTimeout);
|
||||
clearInterval(disposeInterval);
|
||||
}, 10) as any;
|
||||
});
|
||||
}
|
||||
|
||||
export function observe<T extends HTMLElement>(selector: string, cb: (el: T) => any): void {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of Array.from(mutation.addedNodes)) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
if (!node.matches(selector)) continue;
|
||||
|
||||
cb(node as T);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
38
src/common/menu.ts
Normal file
38
src/common/menu.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
interface MenuElement extends HTMLElement {
|
||||
addMenuItem<TElement extends HTMLElement>(el: TElement, index: number): TElement
|
||||
}
|
||||
|
||||
export function ensureMenu(selector: string, cb: () => void): MenuElement {
|
||||
let refEl = document.querySelector<MenuElement>(selector);
|
||||
if (refEl) return createMenu(refEl);
|
||||
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
console.error(`Could not create menu reference element for selector "${selector}".`);
|
||||
}
|
||||
|
||||
refEl = document.querySelector<MenuElement>(selector);
|
||||
if (refEl) return createMenu(refEl);
|
||||
|
||||
throw new Error(`Could not find menu reference element by selector "${selector}".`);
|
||||
}
|
||||
|
||||
function createMenu(menuEl: HTMLElement & MenuElement): MenuElement {
|
||||
menuEl.addMenuItem = (el, index) => {
|
||||
let refEl: HTMLElement = menuEl;
|
||||
|
||||
for (let limit = 0; limit < 100; limit++) {
|
||||
const nextRefEl = refEl.nextElementSibling as HTMLElement;
|
||||
if (!nextRefEl) break;
|
||||
if (Number(nextRefEl.getAttribute("data-index")) > index) break;
|
||||
refEl = nextRefEl;
|
||||
}
|
||||
|
||||
el.setAttribute("data-index", index.toString());
|
||||
refEl.after(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
return menuEl;
|
||||
}
|
||||
31
src/common/misc.ts
Normal file
31
src/common/misc.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export function readCookies(): Record<string, string> {
|
||||
return Object.fromEntries(document.cookie.split("; ").map(cookie => cookie.split("=", 2)));
|
||||
}
|
||||
|
||||
export function sleep(delay: number): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadFile(data: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(data);
|
||||
const el = document.createElement("a");
|
||||
el.style.display = "none";
|
||||
el.download = filename;
|
||||
el.href = url;
|
||||
document.body.appendChild(el);
|
||||
el.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function parallelMap<TInput, TOutput>(values: TInput[], workers: number, fn: (value: TInput) => Promise<TOutput>): Promise<TOutput[]> {
|
||||
const results: TOutput[] = new Array(values.length);
|
||||
const entries = values.entries();
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(values.length, workers) }, async () => {
|
||||
for (const [ index, value ] of entries) results[index] = await fn(value);
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -1,48 +1,32 @@
|
||||
interface CloneIntoOptions {
|
||||
declare global {
|
||||
|
||||
cloneFunctions?: boolean;
|
||||
interface Window {
|
||||
wrappedJSObject: Window | undefined;
|
||||
Promise: typeof Promise;
|
||||
}
|
||||
|
||||
wrapReflectors?: boolean;
|
||||
const cloneInto: CloneIntoFunction<object> | undefined;
|
||||
const exportFunction: ExportFunctionFunction<Function> | undefined;
|
||||
|
||||
}
|
||||
|
||||
interface CloneIntoOptions {
|
||||
cloneFunctions?: boolean;
|
||||
wrapReflectors?: boolean;
|
||||
}
|
||||
|
||||
interface ExportFunctionOptions {
|
||||
|
||||
defineAs?: string;
|
||||
|
||||
allowCrossOriginArguments?: boolean;
|
||||
|
||||
}
|
||||
|
||||
type CloneIntoFunction<T extends object> = (obj: T, targetScope: object, options?: CloneIntoOptions) => T;
|
||||
type ExportFunctionFunction<T extends Function> = (fn: T, targetScope: object, options?: ExportFunctionOptions) => T;
|
||||
|
||||
declare global {
|
||||
export const imposedWindow = unsafeWindow?.wrappedJSObject ?? unsafeWindow;
|
||||
|
||||
interface Window {
|
||||
|
||||
wrappedJSObject: Window | undefined;
|
||||
|
||||
Promise: typeof Promise;
|
||||
|
||||
}
|
||||
|
||||
const cloneInto: CloneIntoFunction<object> | undefined;
|
||||
|
||||
const exportFunction: ExportFunctionFunction<Function> | undefined;
|
||||
|
||||
}
|
||||
|
||||
export let imposedWindow = unsafeWindow;
|
||||
if (imposedWindow.wrappedJSObject) imposedWindow = imposedWindow.wrappedJSObject;
|
||||
|
||||
const cloneIntoImpl: CloneIntoFunction<any> = typeof cloneInto === 'function'
|
||||
? cloneInto
|
||||
: (obj: object) => obj;
|
||||
|
||||
const exportFunctionImpl: ExportFunctionFunction<any> = typeof exportFunction === 'function'
|
||||
? exportFunction
|
||||
: (fn: Function) => fn;
|
||||
const cloneIntoImpl: CloneIntoFunction<any> = typeof cloneInto === "function" ? cloneInto : (obj: object) => obj;
|
||||
const exportFunctionImpl: ExportFunctionFunction<any> = typeof exportFunction === "function" ? exportFunction : (fn: Function) => fn;
|
||||
|
||||
export function exposeObject<T extends object>(obj: T): T {
|
||||
return cloneIntoImpl(obj, imposedWindow, {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { match, MatchFunction } from "path-to-regexp";
|
||||
import { exposeFunction, imposedWindow, imposeFunction } from "./patch";
|
||||
|
||||
export type RouteCallback = () => any;
|
||||
export type RouteCallback = () => void;
|
||||
|
||||
interface Route {
|
||||
pattern: string;
|
||||
matcher: MatchFunction;
|
||||
pattern: RegExp;
|
||||
onCallbacks: RouteCallback[];
|
||||
offCallbacks: RouteCallback[];
|
||||
}
|
||||
|
||||
const registeredRoutes: Route[] = [];
|
||||
const registeredRoutes: Record<string, Route> = {};
|
||||
|
||||
const imposedPushState = imposeFunction(imposedWindow.history.pushState, imposedWindow.history);
|
||||
imposedWindow.history.pushState = exposeFunction(proxyPushState);
|
||||
@@ -25,39 +23,38 @@ addEventListener("popstate", () => {
|
||||
});
|
||||
|
||||
function onNavigate() {
|
||||
for (const route of registeredRoutes) {
|
||||
const cbs = route.matcher(location.pathname) ? route.onCallbacks : route.offCallbacks;
|
||||
for (const route of Object.values(registeredRoutes)) {
|
||||
const cbs = route.pattern.test(location.pathname) ? route.onCallbacks : route.offCallbacks;
|
||||
for (const cb of cbs) cb();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRoute(pattern: string): Route {
|
||||
let route = registeredRoutes.find(route => route.pattern === pattern);
|
||||
const compiledPattern = new RegExp(`^/${pattern.replaceAll("/", "\\/")}$`);
|
||||
|
||||
let route = registeredRoutes[pattern];
|
||||
if (route) return route;
|
||||
|
||||
route = {
|
||||
pattern,
|
||||
matcher: match(pattern),
|
||||
pattern: compiledPattern,
|
||||
onCallbacks: [],
|
||||
offCallbacks: []
|
||||
};
|
||||
|
||||
registeredRoutes.push(route);
|
||||
registeredRoutes[pattern] = route;
|
||||
return route;
|
||||
}
|
||||
|
||||
export function onRoute(pattern: string, cb: RouteCallback, once = false): void {
|
||||
export function onRoute(pattern: string, cb: RouteCallback): void {
|
||||
const route = ensureRoute(pattern);
|
||||
const match = route.matcher(location.pathname);
|
||||
|
||||
const match = route.pattern.test(location.pathname);
|
||||
route.onCallbacks.push(cb);
|
||||
if (match) cb();
|
||||
}
|
||||
|
||||
export function offRoute(pattern: string, cb: RouteCallback, once = false): void {
|
||||
export function offRoute(pattern: string, cb: RouteCallback): void {
|
||||
const route = ensureRoute(pattern);
|
||||
const match = route.matcher(location.pathname);
|
||||
|
||||
const match = route.pattern.test(location.pathname);
|
||||
route.offCallbacks.push(cb);
|
||||
if (!match) cb();
|
||||
}
|
||||
|
||||
4
src/musicbrainz/assets/icons/mhcovers.svg
Normal file
4
src/musicbrainz/assets/icons/mhcovers.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path fill="#dedede" d="M512 255.1c0 1.8-.9 2.7-.9 3.6.5 36.5-32.7 60.4-69.2 60.4H344c-26.5 0-48 22.4-48 48.9 0 3.4.4 6.7 1 9.9 2.2 10.2 6.5 19.2 10.9 29.9 6 13.8 12.1 27.5 12.1 42 0 31.9-21.6 60.7-53.4 62-3.5.1-7.1.2-10.6.2C114.6 512 0 397.4 0 256S114.6 0 256 0s256 114.6 256 256v-.9zm-416 0c-17.67 0-32 15.2-32 32 0 18.6 14.33 32 32 32 17.7 0 32-13.4 32-32 0-16.8-14.3-32-32-32zm32-64c17.7 0 32-13.4 32-32 0-16.8-14.3-32-32-32s-32 15.2-32 32c0 18.6 14.3 32 32 32zm128-128c-17.7 0-32 15.23-32 32 0 18.6 14.3 32 32 32s32-13.4 32-32c0-16.77-14.3-32-32-32zm128 128c17.7 0 32-13.4 32-32 0-16.8-14.3-32-32-32s-32 15.2-32 32c0 18.6 14.3 32 32 32z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 887 B |
BIN
src/musicbrainz/assets/icons/ongakunomori.ico
Normal file
BIN
src/musicbrainz/assets/icons/ongakunomori.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
17
src/musicbrainz/glue/router.ts
Normal file
17
src/musicbrainz/glue/router.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RouteCallback, onRoute } from "../../common/router";
|
||||
|
||||
const RELEASE_PATTERN = "release/[0-9a-f-]+";
|
||||
const RELEASE_VIEW_COVER_PATTERN = "release/[0-9a-f-]+/cover-art";
|
||||
const RELEASE_ADD_COVER_PATTERN = "release/[0-9a-f-]+/add-cover-art";
|
||||
|
||||
export function onReleaseRoute(cb: RouteCallback) {
|
||||
onRoute(RELEASE_PATTERN, cb);
|
||||
}
|
||||
|
||||
export function onReleaseViewCoverRoute(cb: RouteCallback) {
|
||||
onRoute(RELEASE_VIEW_COVER_PATTERN, cb);
|
||||
}
|
||||
|
||||
export function onReleaseAddCoverRoute(cb: RouteCallback) {
|
||||
onRoute(RELEASE_ADD_COVER_PATTERN, cb);
|
||||
}
|
||||
29
src/musicbrainz/glue/sidebar.ts
Normal file
29
src/musicbrainz/glue/sidebar.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { fromHTML } from "../../common/dom";
|
||||
import { ensureMenu } from "../../common/menu";
|
||||
|
||||
export function addReleaseSidebarButton(index: number, icon: string, title: string, link: string): HTMLElement {
|
||||
return addSidebarButton(".release-information", index, icon, title, link);
|
||||
}
|
||||
|
||||
function addSidebarButton(refSelector: string, index: number, icon: string, title: string, link: string): HTMLElement {
|
||||
const menuEl = ensureMenu("#ame-sidebar", () => {
|
||||
const refEl = document.querySelector<HTMLElement>(refSelector)!;
|
||||
const sectionTitleEl = fromHTML(`<h2>Ame</h2>`);
|
||||
const sectionEl = fromHTML(`
|
||||
<ul class="external_links">
|
||||
<li id="ame-sidebar" style="display: none;"></li>
|
||||
</ul>
|
||||
`);
|
||||
|
||||
refEl.before(sectionTitleEl);
|
||||
refEl.before(sectionEl);
|
||||
});
|
||||
|
||||
const itemEl = fromHTML(`
|
||||
<li data-index="${index}" style="background: transparent url('${icon}') center left no-repeat; background-size: 16px 16px;">
|
||||
<a href="${link}">${title}</a>
|
||||
</li>
|
||||
`);
|
||||
|
||||
return menuEl.addMenuItem(itemEl, 100);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @namespace ame-musicbrainz
|
||||
// @name Ame (MusicBrainz)
|
||||
// @version 1.3.0
|
||||
// @version 1.4.0
|
||||
// @author SuperSaltyGamer
|
||||
// @run-at document-end
|
||||
// @match https://musicbrainz.org/*
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
import "./modules/search";
|
||||
import "./modules/covers";
|
||||
import "./modules/scans";
|
||||
import "./modules/related";
|
||||
import styles from "./style.css?inline";
|
||||
|
||||
GM.addStyle(styles);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html } from "lighterhtml";
|
||||
import { fetchCors } from "../../common";
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { fromHTML } from "../../common/dom";
|
||||
import { onReleaseAddCoverRoute } from "../glue/router";
|
||||
import { getPageReleaseInfo } from "../services/release";
|
||||
|
||||
interface CoverData {
|
||||
action: string;
|
||||
@@ -11,55 +13,35 @@ interface CoverData {
|
||||
}
|
||||
}
|
||||
|
||||
const COVERS_URL = "https://covers.musichoarders.xyz";
|
||||
|
||||
const isReleasePage = location.pathname.startsWith("/release/");
|
||||
const isCoverPage = location.pathname.endsWith("/add-cover-art");
|
||||
|
||||
if (isReleasePage && isCoverPage) { // When editing covers of a release.
|
||||
function handleClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
openCovers(true);
|
||||
}
|
||||
|
||||
onReleaseAddCoverRoute(() => {
|
||||
const refEl = document.querySelector(".fileinput-button.buttons");
|
||||
refEl?.appendChild(html.node`
|
||||
<button type="button" onclick="${handleClick}">Pick from MH Covers...</button>
|
||||
`);
|
||||
} else if (isReleasePage) { // When viewing a release.
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!refEl) return;
|
||||
|
||||
const buttonEl = fromHTML(`<button type="button">Pick from MH Covers...</button>`);
|
||||
buttonEl.onclick = openPicker;
|
||||
|
||||
refEl.appendChild(buttonEl);
|
||||
});
|
||||
|
||||
function openPicker(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
openCovers(false);
|
||||
}
|
||||
|
||||
const refEl = document.querySelector("#medium-toolbox");
|
||||
if (refEl) {
|
||||
setTimeout(() => {
|
||||
refEl.insertAdjacentElement("afterbegin", html.node`
|
||||
<button class="btn-link" type="button" onclick="${handleClick}">Search MH Covers</button>
|
||||
`);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
const release = getPageReleaseInfo();
|
||||
if (!release) return;
|
||||
|
||||
function openCovers(isEditing: boolean) {
|
||||
const params = new URLSearchParams();
|
||||
const artist = document.querySelector<HTMLElement>(".subheader bdi")?.innerText ?? "";
|
||||
const album = document.querySelector<HTMLElement>("h1")?.innerText ?? "";
|
||||
params.set("artist", artist);
|
||||
params.set("album", album);
|
||||
if (isEditing) {
|
||||
params.set("artist", release.artist);
|
||||
params.set("album", release.title);
|
||||
params.set("remote.port", "browser");
|
||||
params.set("remote.agent", "Ame - MusicBrainz");
|
||||
params.set("remote.text", "Pick cover for MusicBrainz release.");
|
||||
}
|
||||
|
||||
const win = open(`${COVERS_URL}?${params}`, "_blank");
|
||||
if (!win || !isEditing) return;
|
||||
const win = open(`https://covers.musichoarders.xyz?${params}`, "_blank");
|
||||
if (!win) return;
|
||||
|
||||
// Close covers window when the release page is closed.
|
||||
addEventListener("beforeunload", () => {
|
||||
win?.close();
|
||||
win.close();
|
||||
});
|
||||
|
||||
addEventListener("message", async e => {
|
||||
|
||||
26
src/musicbrainz/modules/related.ts
Normal file
26
src/musicbrainz/modules/related.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { onReleaseRoute } from "../glue/router";
|
||||
import { ReleaseInfo, getPageReleaseInfo } from "../services/release";
|
||||
import { addReleaseSidebarButton } from "../glue/sidebar";
|
||||
import mhCoversIcon from "../assets/icons/mhcovers.svg";
|
||||
import ongakuNoMoriIcon from "../assets/icons/ongakunomori.ico";
|
||||
|
||||
onReleaseRoute(async () => {
|
||||
const release = getPageReleaseInfo();
|
||||
if (!release) return;
|
||||
|
||||
await Promise.all([
|
||||
addOngakuNoMori(release),
|
||||
addMhCovers(release)
|
||||
]);
|
||||
});
|
||||
|
||||
function addOngakuNoMori(release: ReleaseInfo) {
|
||||
const dn = release.barcode ?? release.catalogs[0];
|
||||
if (!dn) return;
|
||||
|
||||
addReleaseSidebarButton(200, ongakuNoMoriIcon, "音楽の森 <small>(Search)</small>", `https://search.minc.or.jp/product/list/?type=search-form-diskno&dn=${dn}`);
|
||||
}
|
||||
|
||||
function addMhCovers(release: ReleaseInfo) {
|
||||
addReleaseSidebarButton(300, mhCoversIcon, "MH Covers <small>(Search)</small>", `https://covers.musichoarders.xyz?artist=${encodeURIComponent(release.artist)}&album=${encodeURIComponent(release.title)}`);
|
||||
}
|
||||
78
src/musicbrainz/modules/scans.ts
Normal file
78
src/musicbrainz/modules/scans.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import JSZip from "jszip";
|
||||
import { fromHTML } from "../../common/dom";
|
||||
import { downloadFile, parallelMap, sleep } from "../../common/misc";
|
||||
import { onReleaseViewCoverRoute } from "../glue/router";
|
||||
import { getPageReleaseInfo } from "../services/release";
|
||||
import { formatPath } from "../../common/format";
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
|
||||
onReleaseViewCoverRoute(() => {
|
||||
const refEl = document.querySelector<HTMLElement>(".buttons.ui-helper-clearfix")!;
|
||||
const downloadButtonEl = fromHTML(`<a class="ame-download-scans"><bdi>Download all scans</bdi></a>`);
|
||||
refEl.appendChild(downloadButtonEl);
|
||||
|
||||
let downloading = false;
|
||||
downloadButtonEl.addEventListener("click", async () => {
|
||||
if (downloading) return;
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
await downloadScans(downloadButtonEl);
|
||||
} catch (err) {
|
||||
downloadButtonEl.innerHTML = `Download all scans (Retry)`;
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
downloading = false;
|
||||
});
|
||||
});
|
||||
|
||||
async function downloadScans(progressEl: HTMLElement) {
|
||||
const zip = new JSZip();
|
||||
|
||||
const release = getPageReleaseInfo();
|
||||
if (!release) return;
|
||||
|
||||
const types: Record<string, number> = {};
|
||||
|
||||
let index = 0;
|
||||
const artworkEls = Array.from(document.querySelectorAll(`.artwork-cont`));
|
||||
|
||||
progressEl.innerHTML = `Download all scans (0/${artworkEls.length})`;
|
||||
|
||||
for (const artworkEl of artworkEls) {
|
||||
index++;
|
||||
|
||||
const downloadEl = artworkEl.querySelector<HTMLLinkElement>("a:last-child");
|
||||
if (!downloadEl) continue;
|
||||
|
||||
const type = artworkEl.querySelector("p")?.innerText.replace("Types:", "").trim();
|
||||
if (!type) continue;
|
||||
|
||||
types[type] = Number(types[type]) + 1 || 1;
|
||||
const typeCount = types[type];
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
const filename = `${type} ${typeCount}.${downloadEl.href.split(".").at(-1)}`;
|
||||
const data = await fetchCors(downloadEl.href).then(res => res.blob());
|
||||
zip.file(formatPath(filename), data);
|
||||
|
||||
progressEl.innerHTML = `Download all scans (${index}/${artworkEls.length})`;
|
||||
await sleep(100);
|
||||
break;
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
progressEl.innerHTML = `Download all scans (Zipping 0%)`;
|
||||
|
||||
const foldername = `Scans {${release.catalogs[0] || release.barcode || release.id}}`;
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" }, (e) => {
|
||||
progressEl.innerHTML = `Download all scans (Zipping ${e.percent.toFixed(0)}%)`;
|
||||
});
|
||||
|
||||
downloadFile(zipBlob, formatPath(`${foldername}.zip`));
|
||||
|
||||
progressEl.innerHTML = `Download all scans (Done)`;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getPageReleaseId } from "../services/release";
|
||||
|
||||
enum QueryType {
|
||||
Other = "other",
|
||||
Catalog = "catalog",
|
||||
@@ -42,8 +44,7 @@ formEl.addEventListener("submit", e => {
|
||||
|
||||
switch (queryType) {
|
||||
case QueryType.Catalog:
|
||||
const catalog = query.split('~')[0];
|
||||
location.href = `https://musicbrainz.org/search?type=release&method=advanced&query=catno:${encodeURIComponent(catalog)}`;
|
||||
location.href = `https://musicbrainz.org/search?type=release&method=advanced&query=catno:${encodeURIComponent(formatCatalog(query))}`;
|
||||
break;
|
||||
case QueryType.Barcode:
|
||||
location.href = `https://musicbrainz.org/search?type=release&method=advanced&query=barcode:${encodeURIComponent(query)}`;
|
||||
@@ -54,7 +55,7 @@ formEl.addEventListener("submit", e => {
|
||||
case QueryType.LogEac:
|
||||
case QueryType.LogXld:
|
||||
let params = `?toc=${parseTocFromLog(query)}`;
|
||||
if (location.pathname.startsWith("/release/")) params += `&filter-release.query=${location.pathname.split("/")[2]}`;
|
||||
if (location.pathname.startsWith("/release/")) params += `&filter-release.query=${getPageReleaseId()}`;
|
||||
location.href = `https://musicbrainz.org/cdtoc/attach${params}`;
|
||||
break;
|
||||
}
|
||||
@@ -69,6 +70,10 @@ export function identifyQuery(value: string): QueryType {
|
||||
return QueryType.Other;
|
||||
}
|
||||
|
||||
function formatCatalog(value: string): string {
|
||||
return /^(.+)([1-9][0-9]*)~([0-9]+)$/.test(value) ? value.split('~')[0] : value;
|
||||
}
|
||||
|
||||
function parseTocFromLog(value: string): string {
|
||||
const entries = [];
|
||||
let lastNo = 0;
|
||||
|
||||
24
src/musicbrainz/services/release.ts
Normal file
24
src/musicbrainz/services/release.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface ReleaseInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
catalogs: string[];
|
||||
barcode?: string;
|
||||
}
|
||||
|
||||
export function getPageReleaseId(): string {
|
||||
return location.pathname.split('/')[2];
|
||||
}
|
||||
|
||||
export function getPageReleaseInfo(): ReleaseInfo | null {
|
||||
let barcode = document.querySelector<HTMLElement>(".barcode")?.innerText;
|
||||
if (barcode === "[none]") barcode = undefined;
|
||||
|
||||
return {
|
||||
id: getPageReleaseId(),
|
||||
title: document.querySelector<HTMLElement>("h1 > a")!.innerText,
|
||||
artist: document.querySelector<HTMLElement>(".subheader bdi")!.innerText,
|
||||
catalogs: Array.from(document.querySelectorAll<HTMLElement>(".catalog-number")).map(el => el.innerText),
|
||||
barcode
|
||||
};
|
||||
}
|
||||
@@ -4,3 +4,10 @@ span.fileinput-button.buttons {
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
/* Minimize sidebar layout shift on release page */
|
||||
.cover-art-image img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
8
src/vgmdb/glue/router.ts
Normal file
8
src/vgmdb/glue/router.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { onRoute } from "../../common/router";
|
||||
import { RouteCallback } from "../../common/router";
|
||||
|
||||
const ALBUM_PATTERN = "album/.+";
|
||||
|
||||
export function onAlbumRoute(cb: RouteCallback) {
|
||||
onRoute(ALBUM_PATTERN, cb);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { onRoute } from "../../common";
|
||||
import { RouteCallback } from "../../common/router";
|
||||
|
||||
export function onAlbumRoute(cb: RouteCallback) {
|
||||
onRoute("/album/:id", cb);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export function addColumnLink(index: number, icon: string, title: string, link: string): HTMLElement {
|
||||
export function addAlbumSidebarButton(index: number, icon: string, title: string, link: string): HTMLElement {
|
||||
let refEl = document.querySelector("#ame-section");
|
||||
if (!refEl) {
|
||||
const sectionEls = document.querySelectorAll("#rightcolumn > br");
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @namespace ame-vgmdb
|
||||
// @name Ame (VGMdb)
|
||||
// @version 1.1.4
|
||||
// @version 1.1.5
|
||||
// @author SuperSaltyGamer
|
||||
// @run-at document-end
|
||||
// @match https://vgmdb.net/*
|
||||
@@ -10,7 +10,7 @@
|
||||
// ==/UserScript==
|
||||
|
||||
import "./modules/scans";
|
||||
import "./modules/album";
|
||||
import "./modules/related";
|
||||
import styles from "./style.css?inline";
|
||||
|
||||
GM.addStyle(styles);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { fetchCors } from "../../common";
|
||||
import { addColumnLink } from "../glue/column";
|
||||
import { onAlbumRoute } from "../glue/routing";
|
||||
import { AlbumInfo, getAlbumInfo, splitCatalog } from "../utils";
|
||||
|
||||
import musicbrainzIcon from "../assets/icons/musicbrainz.ico";
|
||||
import ongakuNoMoriIcon from "../assets/icons/ongakunomori.ico";
|
||||
import mhCoversIcon from "../assets/icons/mhcovers.svg";
|
||||
import { createMusicBrainzReleaseSeeder } from "./musicbrainz";
|
||||
|
||||
onAlbumRoute(async () => {
|
||||
const albumInfo = getAlbumInfo();
|
||||
|
||||
await Promise.all([
|
||||
addMusicBrainz(albumInfo),
|
||||
addOngakuNoMori(albumInfo),
|
||||
addMhCovers(albumInfo)
|
||||
]);
|
||||
});
|
||||
|
||||
async function addMusicBrainz(albumInfo: AlbumInfo) {
|
||||
let query: string[] = [];
|
||||
if (albumInfo.catalog) for (const catalog of splitCatalog(albumInfo.catalog)) query.push(`catno:${catalog}`);
|
||||
if (albumInfo.barcode) query.push(`barcode:${albumInfo.barcode}`);
|
||||
|
||||
function addSeedLink() {
|
||||
function seed(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
createMusicBrainzReleaseSeeder(albumInfo).submit();
|
||||
}
|
||||
|
||||
const linkEl = addColumnLink(100, musicbrainzIcon, "MusicBrainz <small>(Seed)</small>", "#");
|
||||
linkEl.addEventListener("click", seed);
|
||||
linkEl.addEventListener("auxclick", seed);
|
||||
}
|
||||
|
||||
if (query.length === 0) {
|
||||
addSeedLink();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetchCors(`http://musicbrainz.org/ws/2/release/?fmt=json&query=${encodeURIComponent(query.join(" "))}`);
|
||||
const body = await res.json<{ releases: { id: string }[] }>();
|
||||
|
||||
if (body.releases.length === 0) {
|
||||
addSeedLink();
|
||||
} else if (body.releases.length === 1) {
|
||||
addColumnLink(100, musicbrainzIcon, "MusicBrainz", `https://musicbrainz.org/release/${body.releases[0].id}`);
|
||||
} else {
|
||||
addColumnLink(100, musicbrainzIcon, "MusicBrainz <small>(Search)</small>", `https://musicbrainz.org/search?type=release&method=advanced&query=${encodeURIComponent(query.join(" "))}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addOngakuNoMori(albumInfo: AlbumInfo) {
|
||||
const dn = albumInfo.catalog ? splitCatalog(albumInfo.catalog)[0] : albumInfo.barcode;
|
||||
if (!dn) return;
|
||||
|
||||
addColumnLink(200, ongakuNoMoriIcon, "音楽の森 <small>(Search)</small>", `https://search.minc.or.jp/product/list/?type=search-form-diskno&dn=${dn}`);
|
||||
}
|
||||
|
||||
async function addMhCovers(albumInfo: AlbumInfo) {
|
||||
addColumnLink(300, mhCoversIcon, "MH Covers <small>(Search)</small>", `https://covers.musichoarders.xyz?artist=${encodeURIComponent(albumInfo.artist)}&album=${encodeURIComponent(albumInfo.album)}`);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromHTML } from "../../common";
|
||||
import { AlbumInfo, splitCatalog } from "../utils";
|
||||
import { AlbumInfo } from "../services/album";
|
||||
import { fromHTML } from "../../common/dom";
|
||||
|
||||
export function createMusicBrainzReleaseSeeder(albumInfo: AlbumInfo): HTMLFormElement {
|
||||
export function createMusicBrainzReleaseSeeder(album: AlbumInfo): HTMLFormElement {
|
||||
const formEl = fromHTML<HTMLFormElement>(`
|
||||
<form method="POST" target="_blank" action="https://musicbrainz.org/release/add" style="display: none;">
|
||||
<button type="submit">
|
||||
@@ -20,51 +20,51 @@ export function createMusicBrainzReleaseSeeder(albumInfo: AlbumInfo): HTMLFormEl
|
||||
|
||||
const url = `${location.origin}${location.pathname}`;
|
||||
|
||||
set("name", albumInfo.album);
|
||||
if (albumInfo.artist) {
|
||||
set("artist_credit.names.0.name", albumInfo.artist);
|
||||
set("name", album.album);
|
||||
if (album.artist) {
|
||||
set("artist_credit.names.0.name", album.artist);
|
||||
}
|
||||
if (albumInfo.album.match(/[ㄱ-ㅎ가-힣]/)) {
|
||||
if (album.album.match(/[ㄱ-ㅎ가-힣]/)) {
|
||||
set("language", "kor");
|
||||
set("script", "Kore");
|
||||
} else if (albumInfo.album.match(/[一-龯]/)) {
|
||||
} else if (album.album.match(/[一-龯]/)) {
|
||||
set("language", "jpn");
|
||||
set("script", "Jpan");
|
||||
} else {
|
||||
set("language", "eng");
|
||||
set("script", "Latn");
|
||||
}
|
||||
if (albumInfo.publish) {
|
||||
if (albumInfo.publish.includes("promo")) set("status", "promotion");
|
||||
else if (albumInfo.publish.includes("bootleg")) set("status", "bootleg");
|
||||
if (album.publish) {
|
||||
if (album.publish.includes("promo")) set("status", "promotion");
|
||||
else if (album.publish.includes("bootleg")) set("status", "bootleg");
|
||||
else set("status", "official");
|
||||
}
|
||||
if (albumInfo.tracks.length <= 6) {
|
||||
if (album.tracks.length <= 6) {
|
||||
set("type", "single");
|
||||
} else {
|
||||
set("type", "album");
|
||||
}
|
||||
if (!albumInfo.classification) {
|
||||
} else if (albumInfo.classification.includes("soundtrack")) {
|
||||
if (!album.classification) {
|
||||
} else if (album.classification.includes("soundtrack")) {
|
||||
set("type", "soundtrack");
|
||||
} else if (albumInfo.classification.includes("drama")) {
|
||||
} else if (album.classification.includes("drama")) {
|
||||
set("type", "audio drama");
|
||||
} else if (albumInfo.classification.includes("remix")) {
|
||||
} else if (album.classification.includes("remix")) {
|
||||
set("type", "remix");
|
||||
} else if (albumInfo.classification.includes("talk")) {
|
||||
} else if (album.classification.includes("talk")) {
|
||||
set("type", "spokenword");
|
||||
}
|
||||
if (albumInfo.date) {
|
||||
switch (albumInfo.date.length) {
|
||||
if (album.date) {
|
||||
switch (album.date.length) {
|
||||
case 3:
|
||||
set("events.0.date.day", albumInfo.date[2].toString());
|
||||
set("events.0.date.day", album.date[2].toString());
|
||||
case 2:
|
||||
set("events.0.date.month", albumInfo.date[1].toString());
|
||||
set("events.0.date.month", album.date[1].toString());
|
||||
case 1:
|
||||
set("events.0.date.year", albumInfo.date[0].toString());
|
||||
set("events.0.date.year", album.date[0].toString());
|
||||
}
|
||||
}
|
||||
switch (albumInfo.currency) {
|
||||
switch (album.currency) {
|
||||
case "jpy":
|
||||
set("events.0.country", "JP");
|
||||
break;
|
||||
@@ -75,24 +75,23 @@ export function createMusicBrainzReleaseSeeder(albumInfo: AlbumInfo): HTMLFormEl
|
||||
set("events.0.country", "CN");
|
||||
break;
|
||||
}
|
||||
if (albumInfo.barcode) set("barcode", albumInfo.barcode);
|
||||
if (albumInfo.catalog) {
|
||||
const catalogs = splitCatalog(albumInfo.catalog);
|
||||
catalogs.forEach((catalog, i) => {
|
||||
if (album.barcode) set("barcode", album.barcode);
|
||||
if (album.catalog) {
|
||||
album.catalogs.forEach((catalog, i) => {
|
||||
set(`labels.${i}.catalog_number`, catalog);
|
||||
if (albumInfo.label && i !== 0) set(`labels.${i}.name`, albumInfo.label);
|
||||
if (album.label && i !== 0) set(`labels.${i}.name`, album.label);
|
||||
});
|
||||
}
|
||||
if (albumInfo.label) set("labels.0.name", albumInfo.label);
|
||||
if (album.label) set("labels.0.name", album.label);
|
||||
|
||||
albumInfo.mediums.forEach((medium, i) => {
|
||||
album.mediums.forEach((medium, i) => {
|
||||
if (medium) set(`mediums.${i}.format`, medium);
|
||||
});
|
||||
|
||||
let mediumNumber = 0;
|
||||
let trackNumber = 0;
|
||||
|
||||
for (const track of albumInfo.tracks) {
|
||||
for (const track of album.tracks) {
|
||||
if (track.number <= trackNumber) mediumNumber++;
|
||||
trackNumber = track.number;
|
||||
|
||||
@@ -103,7 +102,7 @@ export function createMusicBrainzReleaseSeeder(albumInfo: AlbumInfo): HTMLFormEl
|
||||
|
||||
set("urls.0.url", url);
|
||||
set("urls.0.link_type", "86"); // vgmdb
|
||||
albumInfo.urls.forEach((url, i) => {
|
||||
album.urls.forEach((url, i) => {
|
||||
let type: string | null = null;
|
||||
if (url.includes("cdjapan.co.jp/") || url.includes("yesasia.com/") || url.includes("play-asia.com/")) {
|
||||
type = "79"; // purchase for mail-order
|
||||
|
||||
62
src/vgmdb/modules/related.ts
Normal file
62
src/vgmdb/modules/related.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { addAlbumSidebarButton } from "../glue/sidebar";
|
||||
import { onAlbumRoute } from "../glue/router";
|
||||
import musicbrainzIcon from "../assets/icons/musicbrainz.ico";
|
||||
import ongakuNoMoriIcon from "../assets/icons/ongakunomori.ico";
|
||||
import mhCoversIcon from "../assets/icons/mhcovers.svg";
|
||||
import { createMusicBrainzReleaseSeeder } from "./musicbrainz";
|
||||
import { AlbumInfo, getPageAlbumInfo } from "../services/album";
|
||||
|
||||
onAlbumRoute(async () => {
|
||||
const album = getPageAlbumInfo();
|
||||
|
||||
await Promise.all([
|
||||
addMusicBrainz(album),
|
||||
addOngakuNoMori(album),
|
||||
addMhCovers(album)
|
||||
]);
|
||||
});
|
||||
|
||||
async function addMusicBrainz(album: AlbumInfo) {
|
||||
let query: string[] = [];
|
||||
if (album.catalog) for (const catalog of album.catalogs) query.push(`catno:${catalog}`);
|
||||
if (album.barcode) query.push(`barcode:${album.barcode}`);
|
||||
|
||||
function addSeedLink() {
|
||||
function seed(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
createMusicBrainzReleaseSeeder(album).submit();
|
||||
}
|
||||
|
||||
const linkEl = addAlbumSidebarButton(100, musicbrainzIcon, "MusicBrainz <small>(Seed)</small>", "#");
|
||||
linkEl.addEventListener("click", seed);
|
||||
linkEl.addEventListener("auxclick", seed);
|
||||
}
|
||||
|
||||
if (query.length === 0) {
|
||||
addSeedLink();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetchCors(`http://musicbrainz.org/ws/2/release/?fmt=json&query=${encodeURIComponent(query.join(" "))}`);
|
||||
const body = await res.json<{ releases: { id: string }[] }>();
|
||||
|
||||
if (body.releases.length === 0) {
|
||||
addSeedLink();
|
||||
} else if (body.releases.length === 1) {
|
||||
addAlbumSidebarButton(100, musicbrainzIcon, "MusicBrainz", `https://musicbrainz.org/release/${body.releases[0].id}`);
|
||||
} else {
|
||||
addAlbumSidebarButton(100, musicbrainzIcon, "MusicBrainz <small>(Search)</small>", `https://musicbrainz.org/search?type=release&method=advanced&query=${encodeURIComponent(query.join(" "))}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addOngakuNoMori(album: AlbumInfo) {
|
||||
const dn = album.catalogs[0] ?? album.barcode;
|
||||
if (!dn) return;
|
||||
|
||||
addAlbumSidebarButton(200, ongakuNoMoriIcon, "音楽の森 <small>(Search)</small>", `https://search.minc.or.jp/product/list/?type=search-form-diskno&dn=${dn}`);
|
||||
}
|
||||
|
||||
async function addMhCovers(album: AlbumInfo) {
|
||||
addAlbumSidebarButton(300, mhCoversIcon, "MH Covers <small>(Search)</small>", `https://covers.musichoarders.xyz?artist=${encodeURIComponent(album.artist)}&album=${encodeURIComponent(album.album)}`);
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
import JSZip from "jszip";
|
||||
import { onAlbumRoute } from "../glue/routing";
|
||||
import { downloadFile, fetchCors, sleep } from "../../common";
|
||||
import { getAlbumInfo } from "../utils";
|
||||
import { onAlbumRoute } from "../glue/router";
|
||||
import { downloadFile, sleep } from "../../common/misc";
|
||||
import { fetchCors } from "../../common/fetch";
|
||||
import { getPageAlbumInfo } from "../services/album";
|
||||
import { formatPath } from "../../common/format";
|
||||
import { fromHTML } from "../../common/dom";
|
||||
|
||||
onAlbumRoute(() => {
|
||||
// Select scan gallery tab by default.
|
||||
document.querySelector<HTMLElement>("a[rel='cover_gallery']")?.click();
|
||||
|
||||
const coverGalleryEl = document.querySelector<HTMLElement>("#cover_gallery")!;
|
||||
const downloadEl = document.createElement("a");
|
||||
downloadEl.classList.add("ame-download_button");
|
||||
downloadEl.innerText = "Download all scans";
|
||||
coverGalleryEl.insertAdjacentElement("afterbegin", downloadEl);
|
||||
const downloadButtonEl = fromHTML(`<a class="ame-download-scans">Download all scans</a>`);
|
||||
coverGalleryEl.insertAdjacentElement("afterbegin", downloadButtonEl);
|
||||
|
||||
let downloading = false;
|
||||
downloadEl.addEventListener("click", async () => {
|
||||
downloadButtonEl.addEventListener("click", async () => {
|
||||
if (downloading) return;
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
downloadEl.dataset["status"] = "loading";
|
||||
downloadButtonEl.dataset["status"] = "loading";
|
||||
await downloadScans();
|
||||
downloadEl.dataset["status"] = "success";
|
||||
downloadButtonEl.dataset["status"] = "success";
|
||||
} catch (err) {
|
||||
downloadEl.dataset["status"] = "error";
|
||||
downloadButtonEl.dataset["status"] = "error";
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
@@ -35,16 +35,17 @@ onAlbumRoute(() => {
|
||||
async function downloadScans() {
|
||||
const zip = new JSZip();
|
||||
|
||||
const albumInfo = getAlbumInfo()!;
|
||||
const foldername = formatPath(`Scans {${albumInfo.catalog || albumInfo.barcode || albumInfo.id}}`);
|
||||
const album = getPageAlbumInfo();
|
||||
if (!album) return;
|
||||
|
||||
const els = Array.from(document.querySelectorAll<HTMLLinkElement>(`#cover_gallery a[href^="https://media.vgm.io"]`));
|
||||
for (const el of els) {
|
||||
const data = await fetchCors(el.href).then(res => res.blob());
|
||||
const filename = formatPath(el.querySelector('h4')!.innerText.trim());
|
||||
zip.file(`${filename}.jpg`, data);
|
||||
const scanEls = Array.from(document.querySelectorAll<HTMLLinkElement>(`#cover_gallery a[href^="https://media.vgm.io"]`));
|
||||
for (const scanEl of scanEls) {
|
||||
const data = await fetchCors(scanEl.href).then(res => res.blob());
|
||||
const filename = scanEl.querySelector('h4')!.innerText.trim();
|
||||
zip.file(formatPath(`${filename}.jpg`), data);
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
const foldername = formatPath(`Scans {${album.catalog || album.barcode || album.id}}`);
|
||||
downloadFile(await zip.generateAsync({ type: "blob" }), `${foldername}.zip`);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface AlbumInfo {
|
||||
currency?: string;
|
||||
date?: number[];
|
||||
catalog?: string;
|
||||
catalogs: string[];
|
||||
barcode?: string;
|
||||
mediums: (string | null)[];
|
||||
urls: string[];
|
||||
@@ -18,7 +19,7 @@ export interface AlbumInfo {
|
||||
}[];
|
||||
}
|
||||
|
||||
export function getAlbumInfo(): AlbumInfo {
|
||||
export function getPageAlbumInfo(): AlbumInfo {
|
||||
const artistTitle = (
|
||||
document.querySelector<HTMLElement>(".albumtitle[lang='ja']") ??
|
||||
document.querySelector<HTMLElement>(".albumtitle[lang='en']") ??
|
||||
@@ -33,6 +34,7 @@ export function getAlbumInfo(): AlbumInfo {
|
||||
id: location.pathname.split("/")[2],
|
||||
artist,
|
||||
album: title,
|
||||
catalogs: [],
|
||||
mediums: [],
|
||||
urls: [],
|
||||
tracks: []
|
||||
@@ -51,16 +53,17 @@ export function getAlbumInfo(): AlbumInfo {
|
||||
info.label = value;
|
||||
break;
|
||||
case "Publish Format":
|
||||
info.publish = value?.toLowerCase()?.replace(/[^a-z]/g, "");
|
||||
info.publish = formatSlug(value);
|
||||
break;
|
||||
case "Classification":
|
||||
info.classification = value?.toLowerCase()?.replace(/[^a-z]/g, "");
|
||||
info.classification = formatSlug(value);
|
||||
break;
|
||||
case "Media Format":
|
||||
info.mediums = parseMediums(value);
|
||||
case "Release Price":
|
||||
if (value.includes("USD")) info.currency = "usd";
|
||||
else if (value.includes("JPY")) info.currency = "jpy";
|
||||
else if (value.includes("Japan")) info.currency = "jpy";
|
||||
else if (value.includes("KRW")) info.currency = "krw";
|
||||
else if (value.includes("RMB") || value.includes("CNY")) info.currency = "cny";
|
||||
break;
|
||||
@@ -68,7 +71,10 @@ export function getAlbumInfo(): AlbumInfo {
|
||||
info.date = parseDate(valueEl.innerText);
|
||||
break;
|
||||
case "Catalog Number":
|
||||
if (value !== "N/A") info.catalog = value;
|
||||
if (value !== "N/A") {
|
||||
info.catalog = value;
|
||||
info.catalogs = splitCatalog(value);
|
||||
}
|
||||
break;
|
||||
case "Barcode":
|
||||
if (value !== "N/A") info.barcode = value;
|
||||
@@ -106,8 +112,13 @@ export function getAlbumInfo(): AlbumInfo {
|
||||
return info;
|
||||
}
|
||||
|
||||
export function splitCatalog(value: string): string[] {
|
||||
const match = value.match(/^(.+?)(\d+)~(\d+)$/);
|
||||
function formatSlug(value: string): string
|
||||
function formatSlug(value: string | null): string | null {
|
||||
return value?.toLowerCase().replace(/[^a-z]/g, "") ?? null;
|
||||
}
|
||||
|
||||
function splitCatalog(value: string): string[] {
|
||||
const match = value.match(/^(.+)([1-9][0-9]*)~([0-9]+)$/);
|
||||
if (!match) return [ value ];
|
||||
const start = Number(match[2]);
|
||||
const end = Number(match[2].slice(0, -match[3].length) + match[3]);
|
||||
@@ -1,22 +1,22 @@
|
||||
/* Scans download button. */
|
||||
|
||||
.ame-download_button {
|
||||
.ame-download-scans {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ame-download_button[data-status="loading"] {
|
||||
.ame-download-scans[data-status="loading"] {
|
||||
color: #006ad4;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ame-download_button[data-status="success"] {
|
||||
.ame-download-scans[data-status="success"] {
|
||||
color: #00d46a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ame-download_button[data-status="error"] {
|
||||
.ame-download-scans[data-status="error"] {
|
||||
color: #d40000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user