Compare commits

..

170 commits

Author SHA1 Message Date
Brian Beck
94cf657f30 Update extension docs 2021-04-15 23:12:41 -07:00
Brian Beck
c020795b58 v9.0.0 2021-04-15 21:39:04 -07:00
Brian Beck
42f237d068
Update README.md 2021-04-15 21:38:23 -07:00
Brian Beck
740a499377
Update README.md 2021-04-15 21:37:54 -07:00
Brian Beck
f095cd4de7
Modernize dependencies, syntax, imports (#93)
* wip: Modernize dependencies, syntax, imports

* Use final release of ava-nock v2

* Update Travis config

* Remove Node 13 from test matrix

* Replace errorClass with parseErrorMessage in subclasses

* define exports, apply updated lint rules

* Remove markdown eslint plugin

* Update README

* v9.0.0-beta.1

* Add gql tag to exports

* v9.0.0-beta.2

* Bump ava-nock, add test

* Update dataloader loadMany usage

* Add modules note to README

* Add retry option to got calls
2021-04-15 21:34:29 -07:00
Brian Beck
24d53d4687 Reset lockfile to fix event-stream npm removal 2021-04-10 11:37:01 -07:00
Brian Beck
24ebfa1b51 8.1.0 2018-09-16 21:55:31 -07:00
Brian Beck
cb2d2a1a3f
Media tracks (#87)
* Add support for getting tracks on release objects

* Fix track number type, update docs

* Stop snapshotting tracks query because it blows up AVA

* Fix lint issue
2018-09-16 21:53:15 -07:00
Brian Beck
a6693ed5a4 Remove Greenkeeper badge 2018-09-16 16:41:29 -07:00
Brian Beck
6d3d5a595b v8.0.4 2018-09-16 16:23:06 -07:00
Brian Beck
50d3268366
Fix browse by ISRC and artistCredits (#86)
* Fix browse by ISRC and artist-credits subqueries

* Update test suite
2018-09-16 16:21:34 -07:00
Brian Beck
fc6f1337b6 8.0.3 2018-08-15 20:22:16 -07:00
Brian Beck
01ebffad01
Support fetching ISRCs for nested recording entites (#75) 2018-08-15 20:20:39 -07:00
Brian Beck
ba21977847 Update extension list in intro 2018-08-15 02:18:28 -07:00
Brian Beck
1499e07fa7 Add link to Spotify extension 2018-08-15 02:14:32 -07:00
Brian Beck
50c3223e6a 8.0.2 2018-08-10 18:58:15 -07:00
Brian Beck
d3bb6d2484
Automatically fix some invalid/incomplete musicVideo URLs, return empty arrays instead of null for empty fanart.tv image lists (#73) 2018-08-10 18:56:16 -07:00
Brian Beck
e696ffc70e 8.0.1 2018-08-09 01:09:06 -07:00
Brian Beck
3ed5ba12d7 Fix for missing albums property on fanart.tv artists 2018-08-09 01:04:46 -07:00
Brian Beck
505b679376
Delete circle.yml 2018-08-04 19:51:36 -07:00
Brian Beck
ff828699a0 8.0.0 2018-08-04 15:38:15 -07:00
Brian Beck
d9c029ff71 Use new major release of graphql-markdown 2018-08-04 15:36:11 -07:00
Brian Beck
98e80f1bd1 Update npm package description to mention GraphQL first 2018-08-04 15:08:19 -07:00
Brian Beck
11afa8e4e3 Run heroku open after deploy 2018-08-04 14:00:24 -07:00
Brian Beck
80495a33f0 8.0.0-0 2018-08-04 12:06:48 -07:00
Brian Beck
9f0172ba9d
Bump dependencies, update schemas (#72) 2018-08-04 12:04:19 -07:00
Brian Beck
4768d2f359 Bump dependencies 2017-12-08 23:35:10 -08:00
Brian Beck
f425082758
Add support for retrieving ISO 3166-2 and ISO 3166-3 codes. (#53) 2017-11-23 12:43:40 -08:00
Brian Beck
55176e6753 Fix typo 2017-11-20 02:21:59 -08:00
Brian Beck
eec40c64a0 Add link to Discogs extension 2017-11-20 02:21:12 -08:00
Brian Beck
37e872b1e7
Update README.md 2017-11-19 10:52:19 -08:00
Brian Beck
d8b8de2dce Add note about naming to extensions README 2017-11-18 20:40:35 -08:00
Brian Beck
8aae9d5634 Fix link to schema tests 2017-11-18 20:30:21 -08:00
Brian Beck
37447d1a41 Fix eslintignore 2017-11-18 17:50:50 -08:00
Brian Beck
6d22f0c5b6 v7.3.0 2017-11-18 16:41:17 -08:00
Brian Beck
9ddb4aab67 v7.3.0-0 2017-11-18 15:51:09 -08:00
Brian Beck
14487a747e
Use graphql-tag on extension schemas to get syntax highlighting (#52)
* Use graphql-tag on extension schemas to get syntax highlighting

* Replace graphql-tag with a simple implementation that just uses graphq.parse()
2017-11-18 15:48:23 -08:00
Brian Beck
2d0bd82c8b v7.2.0 2017-11-18 00:40:26 -08:00
Brian Beck
c3be2a2e98
More resolveType fixes, this time for Relay's nodeDefinitions (#51)
* More resolveType fixes, this time for Relay's nodeDefinitions

* Add a loadExtension helper

* Use createContext in test helpers
2017-11-18 00:35:28 -08:00
Brian Beck
2de2e60079
Fix resolveType after the schema has been extended (#50) 2017-11-17 22:34:46 -08:00
Brian Beck
ccce751ccb Bump dependencies 2017-11-16 19:17:58 -08:00
Brian Beck
f34ea2002f Build docs 2017-11-16 19:15:26 -08:00
Brian Beck
edeabeecac Link to Last.fm extension 2017-11-16 19:12:20 -08:00
Brian Beck
5c411ed79d Fix eslintignore 2017-11-16 18:21:45 -08:00
Brian Beck
8c0a9f44ef
Adopt Prettier (but keep Standard) via ESLint (#48) 2017-11-06 21:54:56 -08:00
Brian Beck
50888c9fb9 v7.1.1 2017-11-05 12:03:13 -08:00
Brian Beck
35f6cf63ea
Don't fail if no extensions are passed to createContext options (#47) 2017-11-05 11:59:39 -08:00
Brian Beck
c21009b5c4 v7.1.0 2017-11-04 15:36:13 -07:00
Brian Beck
75e24c18bc
Add missing Area type/typeID, pass start() options to middleware (#46) 2017-11-04 15:32:05 -07:00
Brian Beck
e77143fbd7
Bump deps, use latest updateSchema feature from graphql-markdown (#45)
* Bump deps, use latest updateSchema feature from graphql-markdown
2017-11-01 22:40:52 -07:00
Brian Beck
62495c490d v7.0.0 2017-10-24 21:33:03 -07:00
Brian Beck
e4569607f4 Bump dependencies, add Markdown linting 2017-10-24 21:26:21 -07:00
Brian Beck
7f49ccf117 v7.0.0-3 2017-10-19 21:43:19 -07:00
Brian Beck
d2f4d118fc Add CORS support to the standalone server 2017-10-19 21:40:29 -07:00
Brian Beck
03dc011934 v7.0.0-2 2017-10-19 20:18:59 -07:00
Brian Beck
46d16ebd13 Build schema.json with data as top-level property instead of __schema for Relay compatibility 2017-10-19 20:16:04 -07:00
Brian Beck
be67d771ab Update README.md 2017-10-19 11:12:50 -07:00
Brian Beck
a3fc3e97af Fix Cover Art Archive config showing in MediaWiki extension docs 2017-10-19 08:36:33 -07:00
Brian Beck
dff11f76c8 Fix link 2017-10-19 02:35:11 -07:00
Brian Beck
bbe045a28f Add example query using extensions 2017-10-19 01:55:55 -07:00
Brian Beck
a7183cc15c v7.0.0-1 2017-10-19 01:47:44 -07:00
Brian Beck
51cc879363 Add missing description to MediaWikiImage 2017-10-19 01:45:34 -07:00
Brian Beck
086b7469e1 v7.0.0-0 2017-10-19 01:06:31 -07:00
Brian Beck
aa46d45419 Add extensions dir to npm included files 2017-10-19 01:03:33 -07:00
Brian Beck
898ec78a6f Add a schema extension API and several extensions (#42)
* Add a schema extension API and several extensions
* Update graphql-markdown to use new diffSchema function
* Update Node support
2017-10-19 01:00:21 -07:00
Brian Beck
687ca43708 v6.1.0 2017-10-06 19:46:37 -07:00
Brian Beck
b5d0dcce91 Add a nodes field to every connection (#41)
* Add a nodes field to every connection
* Document nodes field
2017-10-06 19:43:15 -07:00
Brian Beck
bc2a5655d8 Fix stash handling in deploy script 2017-10-05 20:48:51 -07:00
Brian Beck
759310a2a6 v6.0.0 2017-10-05 20:33:35 -07:00
Brian Beck
698ba58492 Bump deps and fix rating type (#38)
* Bump deps and fix rating type
* Add a new test macro, testThrows, due to GraphQL bug
2017-10-05 20:27:53 -07:00
Brian Beck
35db26f8ce Add .babelrc to files 2017-05-15 22:04:34 -07:00
Brian Beck
c9d9cb944b Add src to files 2017-05-15 22:02:25 -07:00
Brian Beck
8447161f29 Add test preinstall script 2017-05-15 21:59:25 -07:00
Brian Beck
2353f9b6c2 Switch back to postinstall-build 2017-05-15 21:38:55 -07:00
Brian Beck
1e535f203e Add shebang 2017-05-15 21:35:45 -07:00
Brian Beck
8807d1e5b8 Try prepare script 2017-05-15 21:34:00 -07:00
Brian Beck
f6ca61233c Point package.json:bin at cli.js 2017-05-15 21:28:54 -07:00
Brian Beck
fd86710fdb Use postinstall-build to support git installs 2017-05-15 20:50:47 -07:00
Brian Beck
3d41c0e3f3 5.1.3 2017-04-27 19:40:36 -07:00
Brian Beck
bd12fc8812 Bump dependencies 2017-04-27 19:34:16 -07:00
Brian Beck
025636d7c3 Fix client submodule link (#29)
Fixes #28. Thanks @lawshe!
2017-04-27 13:06:28 -07:00
Brian Beck
f558eba626 Bump dependencies 2017-04-10 23:34:11 -07:00
greenkeeper[bot]
fc53b15455 Update dependencies to enable Greenkeeper 🌴 (#20)
* chore(package): update dependencies

https://greenkeeper.io/

* chore(travis): whitelist greenkeeper branches 

https://greenkeeper.io/

* docs(readme): add Greenkeeper badge 

https://greenkeeper.io/

* Update yarn.lock, fix badges
2017-04-05 18:19:43 -07:00
Brian Beck
64c624c574 5.1.2 2017-04-03 22:02:49 -07:00
Brian Beck
61efd5d6fa Upgrade graphql-markdown and regenerate docs (#19)
* Upgrade graphql-markdown and regenerate docs

* Use published 2.0.0
2017-04-03 21:59:25 -07:00
Brian Beck
5e9604ffe6 Update rendering of the Schema Types document (#18)
* Update rendering of the Schema Types document

* Use graphql-markdown 1.3.0
2017-04-02 13:12:40 -07:00
Brian Beck
406fede9ea Use new graphql-markdown importable schema 2017-03-26 03:03:12 -07:00
Brian Beck
cf0b52b1ea 5.1.1 2017-03-19 01:11:09 -07:00
Brian Beck
debd296b44 Return node v4 (and add v5) to CI 2017-03-19 01:09:50 -07:00
Brian Beck
4753c3ffa9 Test on Node 6 and 7 2017-03-19 00:56:29 -07:00
Brian Beck
0bf6f8a45a 5.1.0 2017-03-19 00:49:43 -07:00
Brian Beck
fbbb1503e2 Bump dependencies 2017-03-19 00:48:30 -07:00
Brian Beck
1c8d6c4765 Use graphql-markdown package 2017-03-18 14:54:22 -07:00
Brian Beck
593ac5a01f Fix broken markdown 2017-03-18 12:51:47 -07:00
Brian Beck
d60a7eee0c Fix lint 2017-03-17 22:27:25 -07:00
Brian Beck
f9e551a2cf Separate argument table header 2017-03-17 22:20:53 -07:00
Brian Beck
d44fb36e25 Fix trailing space in docs 2017-03-17 22:11:00 -07:00
Brian Beck
8b51b58300 Render list as markdown in <details> element 2017-03-17 21:52:47 -07:00
Brian Beck
8da2911046 Fix markdown in <summary> element due to handle changed GitHub rendering 2017-03-17 21:35:13 -07:00
Brian Beck
244f5cb01d Update TOC 2016-12-20 18:07:24 -08:00
Brian Beck
cfc1b5cc47 5.0.3 2016-12-20 18:06:33 -08:00
Brian Beck
53d7d84397 Add client usage section to README 2016-12-20 18:04:08 -08:00
Brian Beck
1d63867e07 Fix typo in cover art docs 2016-12-19 21:53:25 -08:00
Brian Beck
2b69904ff8 5.0.2 2016-12-19 21:51:09 -08:00
Brian Beck
639a53cd68 Add preversion script to hopefully kill forgotten docs updates 2016-12-19 21:49:16 -08:00
Brian Beck
bbefb27cea 5.0.1 2016-12-19 21:29:20 -08:00
Brian Beck
168648c6e6 Update docs 2016-12-19 21:27:22 -08:00
Brian Beck
c12e97f83d 5.0.0 2016-12-19 21:23:42 -08:00
Brian Beck
4cac7ac76c Add support for cover art via the Cover Art Archive (#17) 2016-12-19 21:21:07 -08:00
Brian Beck
bbf2ab6c30 Refresh fixtures 2016-12-14 23:18:53 -08:00
Brian Beck
35a1f0f86b Bump dependencies, tweak descriptions and field/arg order 2016-12-14 23:18:53 -08:00
Brian Beck
b16873f296 4.5.0 2016-12-13 21:24:12 -08:00
Brian Beck
7af6168b85 Update badges 2016-12-13 21:21:07 -08:00
Brian Beck
780596480a Add Disc type, lookup, and discs field on Media (#16)
* Add Disc type, lookup, and discs field on Media
* Remove query optimization so search subqueries work
2016-12-13 21:18:33 -08:00
Brian Beck
7d45b44cda Add ratings (#15) 2016-12-13 20:50:38 -08:00
Brian Beck
c2d2bfd246 Add more scalar tests 2016-12-13 20:05:35 -08:00
Brian Beck
003585fb86 Fix beginArea description 2016-12-13 20:05:35 -08:00
Brian Beck
e27d08b171 Add more tests for scalars 2016-12-13 00:32:21 -08:00
Brian Beck
bb8f277c13 New relationship tests, rewrite test names 2016-12-12 23:25:23 -08:00
Brian Beck
6ae2fa259d Tweak collections field description, try codecov.io reporting 2016-12-12 09:43:17 -08:00
Brian Beck
8bc710091c 4.4.0 2016-12-12 01:07:24 -08:00
Brian Beck
4f9ba30977 Bump pre-commit dep 2016-12-12 01:05:25 -08:00
Brian Beck
d906ec1086 Add support for release media (#14) 2016-12-12 01:01:40 -08:00
Brian Beck
ae9330f1d0 Update docs 2016-12-12 00:55:15 -08:00
Brian Beck
9601b400b5 Give every entity a collections field (#13) 2016-12-12 00:34:26 -08:00
Brian Beck
c2f7d9b836 Move relationship connection args to end 2016-12-12 00:02:58 -08:00
Brian Beck
8b8338b332 Add Node.resolveType tests 2016-12-11 21:56:28 -08:00
Brian Beck
9247d34f8c 4.3.1 2016-12-11 21:14:00 -08:00
Brian Beck
441c9cd8b8 Reorder args so connection pagination args come last 2016-12-11 21:09:37 -08:00
Brian Beck
fb6d9d8b98 4.3.0 2016-12-11 20:07:59 -08:00
Brian Beck
ef8e67c8a6 Add support for browsing and looking up collections (#12)
Fixes #6.
2016-12-11 20:03:05 -08:00
Brian Beck
0600c2026b Use Duration scalar for recording lengths (#11) 2016-12-11 16:12:54 -08:00
Brian Beck
c4c023c750 4.2.0 2016-12-11 13:14:04 -08:00
Brian Beck
01b305dd50 Support browsing by Disc ID, ISRC, ISWC (#10)
* Browse queries for releases, recordings, and works now support
  browsing by Disc ID, ISRC, and ISWC, respectively
* Add `DiscID`, `ISRC`, and `ISWC` scalars
* Add missing `isrcs` field to `Recording`

Fixes #7.
2016-12-11 13:09:28 -08:00
Brian Beck
195e9fdb7c Reconsider test indentation 2016-12-11 12:37:25 -08:00
Brian Beck
b2ec20ed2c Rename artistCredit fields to artistCredits (#9)
* Rename artistCredit field to artistCredits
* Update deprecation rendering
2016-12-11 12:32:58 -08:00
Brian Beck
fdac30b999 4.1.0 2016-12-10 09:57:57 -08:00
Brian Beck
090a6e4629 Merge pull request #5 from exogen/add-artist-ipis-isnis
Add list of IPIs and ISNIs to Artist
2016-12-10 09:55:45 -08:00
Brian Beck
94cdf5922f Add list of IPIs and ISNIs to Artist 2016-12-10 09:51:33 -08:00
Brian Beck
deff412f8a Fix schema tests link 2016-12-09 19:42:54 -08:00
Brian Beck
29ff8d4d87 Link to schema tests 2016-12-09 19:42:13 -08:00
Brian Beck
705f76b5b1 Specify stricter engine version 2016-12-09 19:40:56 -08:00
Brian Beck
9f3122c9c6 4.0.0 2016-12-09 19:02:07 -08:00
Brian Beck
42f612cc2f Update schema and docs 2016-12-09 18:56:45 -08:00
Brian Beck
325c6db0ce Add support for more MusicBrainz features, improve test coverage 2016-12-09 18:55:41 -08:00
Brian Beck
b6f2e2d3f7 Move test client creation to before() 2016-12-08 16:08:11 -08:00
Brian Beck
ed2c68ef22 3.1.1 2016-12-08 14:50:03 -08:00
Brian Beck
3252b52dc1 Add tests, fix cases where loaders are not passed params 2016-12-08 14:33:18 -08:00
Brian Beck
5b41af6e6a Improve coverage with more schema tests 2016-12-08 14:11:05 -08:00
Brian Beck
31fecabfda Request more fields to increase coverage 2016-12-07 15:10:03 -08:00
Brian Beck
d5067159f9 Add pre-commit hook 2016-12-07 06:25:22 -08:00
Brian Beck
690cdee28f Fix lint 2016-12-07 06:21:39 -08:00
Brian Beck
a72a0365d1 Add more schema tests 2016-12-07 06:16:30 -08:00
Brian Beck
33d45710fe 3.1.0 2016-12-07 00:46:09 -08:00
Brian Beck
fe9f03aac5 Remove test task from prepublish 2016-12-07 00:42:07 -08:00
Brian Beck
1ace6f55e5 Merge pull request #4 from exogen/add-tests-and-coverage
Switch to AVA, add new tests and coverage
2016-12-07 00:40:00 -08:00
Brian Beck
8491296400 Add coveralls badge 2016-12-07 00:37:49 -08:00
Brian Beck
6b469d4fc5 Send results to Coveralls 2016-12-07 00:26:14 -08:00
Brian Beck
19e7f978eb Add new tests and coverage 2016-12-07 00:23:02 -08:00
Brian Beck
4c4af1fd16 Add missing tr in thead 2016-12-03 00:42:06 -08:00
Brian Beck
7fb971180d Bump dependencies 2016-12-03 00:13:55 -08:00
Brian Beck
e5072de21a Update log messages 2016-12-02 23:59:19 -08:00
Brian Beck
d7aac5220e Add note about aliases 2016-12-02 17:47:46 -08:00
Brian Beck
05edff8398 3.0.1 2016-12-02 00:50:37 -08:00
Brian Beck
7832f608a0 Update docs & generated schema 2016-12-02 00:49:39 -08:00
Brian Beck
bb762f135f 3.0.0 2016-12-02 00:48:27 -08:00
Brian Beck
0ad7d68e49 manual license badge to remove redundant License text 2016-12-02 00:44:45 -08:00
Brian Beck
4bb4430d75 Add badges 2016-12-02 00:38:41 -08:00
Brian Beck
6cb41e1ce0 Internal renames, update dependencies, add Travis config 2016-12-02 00:21:10 -08:00
Brian Beck
3dbbf54161 Add note about query times 2016-11-30 23:24:19 -08:00
130 changed files with 48969 additions and 11705 deletions

View file

@ -1,3 +0,0 @@
{
"presets": ["es2015", "stage-2"]
}

4
.eslintignore Normal file
View file

@ -0,0 +1,4 @@
/.nyc_output
/coverage
/lib
!.eslintrc.cjs

45
.eslintrc.cjs Normal file
View file

@ -0,0 +1,45 @@
module.exports = {
env: {
es2020: true,
node: true,
},
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
extends: [
'eslint:recommended',
'plugin:promise/recommended',
'plugin:node/recommended',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:prettier/recommended',
],
rules: {
'no-unused-vars': [
'error',
{ vars: 'all', args: 'none', ignoreRestSiblings: false },
],
'import/default': 'off',
'import/no-named-as-default': 'off',
'node/no-unsupported-features/es-syntax': [
'error',
{
ignores: ['dynamicImport', 'modules'],
},
],
'prettier/prettier': [
'error',
{
singleQuote: true,
},
],
'promise/always-return': 'off',
'promise/catch-or-return': [
'error',
{
allowThen: true,
},
],
},
};

1
.gitignore vendored
View file

@ -36,4 +36,5 @@ jspm_packages
# Optional REPL history
.node_repl_history
.env
lib

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
/.nyc_output
/coverage
/lib

26
.travis.yml Normal file
View file

@ -0,0 +1,26 @@
language: node_js
node_js:
- '12'
- '14'
- '15'
# Use container-based Travis infrastructure.
sudo: false
branches:
only:
- master
- '/^greenkeeper/.*$/'
env:
global:
- secure: fyw/6iyz4skp/sdAb9wr9Pq/72j4z2TiIZIJLT6B4zZZUKclA9ihHYx2Qf4IK7rHe+mvg0dSKaJi+zgrEx04fg2bP05dV1f52mmR71sE/FDRc9nkGgDl6/2AeXfXh0Raf8lVsYuBWclI2G0GPQeG1xskY7EBEnlKBdRmnljXPDNu1kwSrcLoGkoP1sIMRIxnMAHPXlOO3QKstiHM/HMa3A4SsQ4VGVLfrYqDJJ3L3XOkSbqyaSdWhELxSXAxSEzEOYi28kPeKtztdaU+BQtOSamuIMrEPwpM6+bFtTrf/SxWcybCk2sqn4sUgDVE8658cEIECR6NC1U/gWvDZSVHEetT2caHkhe1NRjxQmKrRfx88a+qZPeqbv6ydpvK8XAnaMtpwZtytoPZ7gw4IF0wxfkeBhK8dgBb4Lsdpnv90tbzcIyHgrHJiKBgJLlghBr9McFo4Fe3W06eD4rBTNX1LCmURQCm0V6mfCPUPup/68tAuscgysVW54XT51P4fk1iR3MJzCjbxaWbxwxWdCEeD2ptIkZwXDGydVe2qL4eI7NpYyTyhHc8vITlXYQnDCj9ztwjSiSf+4jJQiN7mbNcrRymnF22ICubnpj0SHBxEkQFJ+f/Wf3Ksxtl8t8DLTBCPtSzv7rxbdtnKRR1QRWgeYnhos1QHcNaoDgrlWCkOIg=
- secure: gS7EN+j/l9SDLjwM9YHCgOh6iPw0YTKvuQX9eXUI9YIEYoyhnBRQMfp77LJTuXdlucGgYj4v05YriLoxl4L7hmUmAKnDOVVEOUQqwYoAzg+h2GS3FQhoQRioxSAmTJc90nki0uhYAHW8FZ34+BjONCDSnew7r71TTU+UxRdRu4wOneFwXW1crPCNRtO5Ov/gkuiOWQaSPOoP6tzPOMP6FUueRQVqvf2GOoF3lBM8LnKeIq+m4H80DGsNjMtqdPJz9QQzAwO3VRNgsMAgr2wzVzjUnum7DfQC808AQDURlj9apg1HdYZDJH9bQyI/jIZ1gnHIcZ+kqqoq0l0QVNmITeMGrblFALU8xCFtGUyq70FGMjcmDGCanpveNOOhxjdqqfvvPKuqHEsfZtZkBJDovJdFtKmzf7DWb5xv8liPCzX26eBG/DPQgCEzyW0bl7IcB2LVMW/ObMRgixik2qiqYjN05pveWYsiO2iaiMo9ebm7FHXw/B0yfjM+CCGf7bjR9+RczXlN1x/gj+yjnYJ4K/nojSx0wms3QE7tJsXjmAMeguKUoZtaGGlF5yGbKtaZEVw/ZhYQDfLwwHQX2S5E424tYP/NAdi++CNCvyHJ7n0zel18FfsUNE1jIMaGb6aG6uOv6eOVI4+O09R71wxcYbQs40axdF+Y0A5f+CllNc8=
script:
- yarn test
after_success:
- $(yarn bin)/c8 report --reporter=text-lcov | $(yarn bin)/coveralls
- bash <(curl -s https://codecov.io/bash)

216
README.md
View file

@ -1,28 +1,50 @@
# graphbrainz
# GraphBrainz
An [Express][] server and middleware for querying [MusicBrainz][] using
[GraphQL][].
[![build status](https://img.shields.io/travis/exogen/graphbrainz/master.svg)](https://travis-ci.org/exogen/graphbrainz)
[![coverage](https://img.shields.io/codecov/c/github/exogen/graphbrainz.svg)](https://codecov.io/gh/exogen/graphbrainz)
[![npm version](https://img.shields.io/npm/v/graphbrainz.svg)](https://www.npmjs.com/package/graphbrainz)
[![license](https://img.shields.io/npm/l/graphbrainz.svg)](https://github.com/exogen/graphbrainz/blob/master/LICENSE)
A [GraphQL][] schema, [Express][] server, and middleware for querying the
[MusicBrainz][] API. It features an [extensible](./docs/extensions) schema to
add integration with Discogs, Spotify, Last.fm, fanart.tv, and more!
**[Try out the live demo!][demo]** :bulb: Use the “Docs” sidebar, the
[schema][], or the [types][] docs to help construct your query.
## Install
Install with npm:
```sh
npm install graphbrainz --save
```
**[Try out the live demo!][demo]** :bulb: Use the “Docs” sidebar, the
[schema][], or the [types][] docs to help construct your query.
Install with Yarn:
```sh
yarn add graphbrainz
```
_GraphBrainz is written and distributed as native ECMAScript modules
(ESM) and requires a compatible version of Node.js_
## Contents
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
## Contents
- [Usage](#usage)
- [As a standalone server](#as-a-standalone-server)
- [As middleware](#as-middleware)
- [As a client](#as-a-client)
- [Environment Variables](#environment-variables)
- [Debugging](#debugging)
- [Example Queries](#example-queries)
- [Pagination](#pagination)
- [Questions](#questions)
- [Schema](#schema)
- [Extending the schema](#extending-the-schema)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@ -33,8 +55,8 @@ middleware supplying a GraphQL endpoint.
### As a standalone server
Run the included `graphbrainz` executable to start the server. The server
is configured using [environment variables](#environment-variables).
Run the included `graphbrainz` executable to start the server. The server is
configured using [environment variables](#environment-variables).
```sh
$ graphbrainz
@ -59,7 +81,7 @@ an endpoint, or you just want more customization, use the middleware.
```js
import express from 'express';
import graphbrainz from 'graphbrainz';
import { middleware as graphbrainz } from 'graphbrainz';
const app = express();
@ -78,30 +100,74 @@ app.listen(3000);
The `graphbrainz` middleware function accepts the following options:
* **`client`**: A custom API client instance to use. See the
[client submodule](src/api.js) for help with creating a custom instance. You
probably only need to do this if you want to adjust the rate limit and retry
behavior.
* Any remaining options are passed along to the standard GraphQL middleware.
See the [express-graphql][] documentation for more information.
- **`client`**: A custom API client instance to use. See the
[client submodule](src/api/client.js) for help with creating a custom
instance. You probably only need to do this if you want to adjust the rate
limit and retry behavior.
- Any remaining options are passed along to the standard GraphQL middleware. See
the [express-graphql][] documentation for more information.
### As a client
If you just want to make queries from your app without running a separate server
or exposing a GraphQL endpoint, use the GraphBrainz schema with a library like
[GraphQL.js][graphql-js]. You just need to create the `context` object that the
GraphBrainz resolvers expect, like so:
```js
import { graphql } from 'graphql';
import { MusicBrainz, createContext, baseSchema } from 'graphbrainz';
const client = new MusicBrainz();
const context = createContext({ client });
graphql(
schema,
`
{
lookup {
releaseGroup(mbid: "99599db8-0e36-4a93-b0e8-350e9d7502a9") {
title
}
}
}
`,
null,
context
)
.then((result) => {
const { releaseGroup } = result.data.lookup;
console.log(`The album title is “${releaseGroup.title}”.`);
})
.catch((err) => {
console.error(err);
});
```
### Environment Variables
* **`MUSICBRAINZ_BASE_URL`**: The base MusicBrainz API URL to use. Change this
if you are running your own MusicBrainz mirror. Defaults to `http://musicbrainz.org/ws/2/`.
* **`GRAPHBRAINZ_PATH`**: The URL route at which to expose the GraphQL endpoint,
- **`MUSICBRAINZ_BASE_URL`**: The base MusicBrainz API URL to use. Change this
if you are running your own MusicBrainz mirror. Defaults to
`http://musicbrainz.org/ws/2/`.
- **`GRAPHBRAINZ_PATH`**: The URL route at which to expose the GraphQL endpoint,
if running the standalone server. Defaults to `/`.
* **`GRAPHBRAINZ_CACHE_SIZE`**: The maximum number of REST API responses to
- **`GRAPHBRAINZ_CORS_ORIGIN`**: The value of the `origin` option to pass to the
[CORS][cors] middleware. Valid values are `true` to reflect the request
origin, a specific origin string to allow, `*` to allow all origins, and
`false` to disable CORS (the default).
- **`GRAPHBRAINZ_CACHE_SIZE`**: The maximum number of REST API responses to
cache. Increasing the cache size and TTL will greatly lower query execution
time for complex queries involving frequently accessed entities. Defaults to
`8192`.
* **`GRAPHBRAINZ_CACHE_TTL`**: The maximum age of REST API responses in the
- **`GRAPHBRAINZ_CACHE_TTL`**: The maximum age of REST API responses in the
cache, in milliseconds. Responses older than this will be disposed of (and
re-requested) the next time they are accessed. Defaults to `86400000` (one
day).
* **`GRAPHBRAINZ_GRAPHIQL`**: Set this to `true` if you want to force the
- **`GRAPHBRAINZ_GRAPHIQL`**: Set this to `true` if you want to force the
[GraphiQL][] interface to be available even in production mode.
* **`PORT`**: Port number to use, if running the standalone server.
- **`GRAPHBRAINZ_EXTENSIONS`**: A JSON array of module paths to load as
[extensions](./docs/extensions).
- **`PORT`**: Port number to use, if running the standalone server.
When running the standalone server, [dotenv][] is used to load these variables
from a `.env` file, if one exists in the current working directory. This just
@ -121,7 +187,8 @@ See the [debug][] package for more information.
## Example Queries
Nirvana albums and each albums singles ([try it](https://graphbrainz.herokuapp.com/?query=query%20NirvanaAlbumSingles%20%7B%0A%20%20lookup%20%7B%0A%20%20%20%20artist(mbid%3A%20%225b11f4ce-a62d-471e-81fc-a69a8278c7da%22)%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20releaseGroups(type%3A%20ALBUM)%20%7B%0A%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20%20%20firstReleaseDate%0A%20%20%20%20%20%20%20%20%20%20%20%20relationships%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20releaseGroups(type%3A%20%22single%20from%22)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20target%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20...%20on%20ReleaseGroup%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20firstReleaseDate%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=NirvanaAlbumSingles)):
Nirvana albums and each albums singles
([try it](<https://graphbrainz.herokuapp.com/?query=query%20NirvanaAlbumSingles%20%7B%0A%20%20lookup%20%7B%0A%20%20%20%20artist(mbid%3A%20%225b11f4ce-a62d-471e-81fc-a69a8278c7da%22)%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20releaseGroups(type%3A%20ALBUM)%20%7B%0A%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20%20%20firstReleaseDate%0A%20%20%20%20%20%20%20%20%20%20%20%20relationships%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20releaseGroups(type%3A%20%22single%20from%22)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20target%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20...%20on%20ReleaseGroup%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20firstReleaseDate%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=NirvanaAlbumSingles>)):
```graphql
query NirvanaAlbumSingles {
@ -157,7 +224,8 @@ query NirvanaAlbumSingles {
### Pagination
The first five labels with “Apple” in the name ([try it](https://graphbrainz.herokuapp.com/?query=query%20AppleLabels%20%7B%0A%20%20search%20%7B%0A%20%20%20%20labels(query%3A%20%22Apple%22%2C%20first%3A%205)%20%7B%0A%20%20%20%20%20%20...labelResults%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20labelResults%20on%20LabelConnection%20%7B%0A%20%20pageInfo%20%7B%0A%20%20%20%20endCursor%0A%20%20%7D%0A%20%20edges%20%7B%0A%20%20%20%20cursor%0A%20%20%20%20node%20%7B%0A%20%20%20%20%20%20mbid%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20type%0A%20%20%20%20%20%20area%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=AppleLabels)):
The first five labels with “Apple” in the name
([try it](<https://graphbrainz.herokuapp.com/?query=query%20AppleLabels%20%7B%0A%20%20search%20%7B%0A%20%20%20%20labels(query%3A%20%22Apple%22%2C%20first%3A%205)%20%7B%0A%20%20%20%20%20%20...labelResults%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20labelResults%20on%20LabelConnection%20%7B%0A%20%20pageInfo%20%7B%0A%20%20%20%20endCursor%0A%20%20%7D%0A%20%20edges%20%7B%0A%20%20%20%20cursor%0A%20%20%20%20node%20%7B%0A%20%20%20%20%20%20mbid%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20type%0A%20%20%20%20%20%20area%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=AppleLabels>)):
```graphql
query AppleLabels {
@ -186,7 +254,8 @@ fragment labelResults on LabelConnection {
}
```
…and the next five, using the `endCursor` from the previous result ([try it](https://graphbrainz.herokuapp.com/?query=query%20AppleLabels%20%7B%0A%20%20search%20%7B%0A%20%20%20%20labels(query%3A%20%22Apple%22%2C%20first%3A%205%2C%20after%3A%20%22YXJyYXljb25uZWN0aW9uOjQ%3D%22)%20%7B%0A%20%20%20%20%20%20...labelResults%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20labelResults%20on%20LabelConnection%20%7B%0A%20%20pageInfo%20%7B%0A%20%20%20%20endCursor%0A%20%20%7D%0A%20%20edges%20%7B%0A%20%20%20%20cursor%0A%20%20%20%20node%20%7B%0A%20%20%20%20%20%20mbid%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20type%0A%20%20%20%20%20%20area%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=AppleLabels)):
…and the next five, using the `endCursor` from the previous result
([try it](<https://graphbrainz.herokuapp.com/?query=query%20AppleLabels%20%7B%0A%20%20search%20%7B%0A%20%20%20%20labels(query%3A%20%22Apple%22%2C%20first%3A%205%2C%20after%3A%20%22YXJyYXljb25uZWN0aW9uOjQ%3D%22)%20%7B%0A%20%20%20%20%20%20...labelResults%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20labelResults%20on%20LabelConnection%20%7B%0A%20%20pageInfo%20%7B%0A%20%20%20%20endCursor%0A%20%20%7D%0A%20%20edges%20%7B%0A%20%20%20%20cursor%0A%20%20%20%20node%20%7B%0A%20%20%20%20%20%20mbid%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20type%0A%20%20%20%20%20%20area%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=AppleLabels>)):
```graphql
query AppleLabels {
@ -199,7 +268,7 @@ query AppleLabels {
```
Who the members of the band on an Apple Records release married, and when
([try it](https://graphbrainz.herokuapp.com/?query=query%20AppleRecordsMarriages%20%7B%0A%20%20search%20%7B%0A%20%20%20%20labels(query%3A%20%22Apple%20Records%22%2C%20first%3A%201)%20%7B%0A%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20disambiguation%0A%20%20%20%20%20%20%20%20%20%20country%0A%20%20%20%20%20%20%20%20%20%20releases(first%3A%201)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20date%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20artists%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20...bandMembers%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20bandMembers%20on%20Artist%20%7B%0A%20%20relationships%20%7B%0A%20%20%20%20artists(direction%3A%20%22backward%22%2C%20type%3A%20%22member%20of%20band%22)%20%7B%0A%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20type%0A%20%20%20%20%20%20%20%20%20%20target%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20...%20on%20Artist%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20...marriages%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20marriages%20on%20Artist%20%7B%0A%20%20relationships%20%7B%0A%20%20%20%20artists(type%3A%20%22married%22)%20%7B%0A%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20type%0A%20%20%20%20%20%20%20%20%20%20direction%0A%20%20%20%20%20%20%20%20%20%20begin%0A%20%20%20%20%20%20%20%20%20%20end%0A%20%20%20%20%20%20%20%20%20%20target%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20...%20on%20Artist%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=AppleRecordsMarriages)):
([try it](<https://graphbrainz.herokuapp.com/?query=query%20AppleRecordsMarriages%20%7B%0A%20%20search%20%7B%0A%20%20%20%20labels(query%3A%20%22Apple%20Records%22%2C%20first%3A%201)%20%7B%0A%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20disambiguation%0A%20%20%20%20%20%20%20%20%20%20country%0A%20%20%20%20%20%20%20%20%20%20releases(first%3A%201)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20date%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20artists%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20...bandMembers%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20bandMembers%20on%20Artist%20%7B%0A%20%20relationships%20%7B%0A%20%20%20%20artists(direction%3A%20%22backward%22%2C%20type%3A%20%22member%20of%20band%22)%20%7B%0A%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20type%0A%20%20%20%20%20%20%20%20%20%20target%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20...%20on%20Artist%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20...marriages%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A%0Afragment%20marriages%20on%20Artist%20%7B%0A%20%20relationships%20%7B%0A%20%20%20%20artists(type%3A%20%22married%22)%20%7B%0A%20%20%20%20%20%20edges%20%7B%0A%20%20%20%20%20%20%20%20node%20%7B%0A%20%20%20%20%20%20%20%20%20%20type%0A%20%20%20%20%20%20%20%20%20%20direction%0A%20%20%20%20%20%20%20%20%20%20begin%0A%20%20%20%20%20%20%20%20%20%20end%0A%20%20%20%20%20%20%20%20%20%20target%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20...%20on%20Artist%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=AppleRecordsMarriages>)):
```graphql
query AppleRecordsMarriages {
@ -271,28 +340,107 @@ fragment marriages on Artist {
}
```
Images of Tom Petty provided by various extensions
([try it](<https://graphbrainz.herokuapp.com/?query=query%20TomPettyImages%20%7B%0A%20%20lookup%20%7B%0A%20%20%20%20artist(mbid%3A%20%225ca3f318-d028-4151-ac73-78e2b2d6cdcc%22)%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20mediaWikiImages%20%7B%0A%20%20%20%20%20%20%20%20url%0A%20%20%20%20%20%20%20%20objectName%0A%20%20%20%20%20%20%20%20descriptionHTML%0A%20%20%20%20%20%20%20%20licenseShortName%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20fanArt%20%7B%0A%20%20%20%20%20%20%20%20thumbnails%20%7B%0A%20%20%20%20%20%20%20%20%20%20url%0A%20%20%20%20%20%20%20%20%20%20likeCount%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20theAudioDB%20%7B%0A%20%20%20%20%20%20%20%20logo%0A%20%20%20%20%20%20%20%20biography%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A&operationName=TomPettyImages>)):
```graphql
query TomPettyImages {
lookup {
artist(mbid: "5ca3f318-d028-4151-ac73-78e2b2d6cdcc") {
name
mediaWikiImages {
url
objectName
descriptionHTML
licenseShortName
}
fanArt {
thumbnails {
url
likeCount
}
}
theAudioDB {
logo
biography
}
}
}
}
```
You can find more example queries in the [schema tests][].
## Questions
**Whats with the cumbersome `edges`/`node` nesting? Why `first`/`after`
instead of `limit`/`offset`? Why `mbid` instead of `id`?**
**Whats with the cumbersome `edges`/`node` nesting? Why `first`/`after` instead
of `limit`/`offset`? Why `mbid` instead of `id`?**
You can thank [Relay][] for that; these are properties of a Relay-compliant
schema. The schema was originally designed to be more user-friendly, but in the
end I decided that being compatible with Relay was a worthwhile feature. I
agree, its ugly.
The GraphBrainz schema includes an extra `nodes` field on every connection type.
If you only want the nodes and no other fields on `edges`, you can use `nodes`
as a shortcut.
Dont forget that you can also use [GraphQL aliases][aliases] to rename fields
to your liking. For example, the following query renames `edges`, `node`, and
`mbid` to `results`, `releaseGroup`, and `id`, respectively:
```graphql
query ChristmasAlbums {
search {
releaseGroups(query: "Christmas") {
results: edges {
releaseGroup: node {
id: mbid
title
}
}
}
}
}
```
**Why does my query take so long?**
Its likely that your query requires multiple round trips to the MusicBrainz
REST API, which is subject to [rate limiting][]. While the query resolver tries
very hard to fetch only the data necessary, and with the smallest number of API
requests, it is not 100% optimal (yet). Make sure you are only requesting the
fields you need and a reasonable level of nested entities  unless you are
willing to wait.
You can also set up a [local MusicBrainz mirror][mirror] and configure
GraphBrainz to use that with no rate limiting.
## Schema
See the [GraphQL schema][schema] or the [types][] documentation.
The [types][] document is the easiest to browse representation of the schema, or
you can read the [schema in GraphQL syntax][schema].
### Extending the schema
The GraphBrainz schema can easily be extended to add integrations with
third-party services. See the [Extensions](./docs/extensions) docs for more
info.
[demo]: https://graphbrainz.herokuapp.com/
[Express]: http://expressjs.com/
[MusicBrainz]: https://musicbrainz.org/
[GraphQL]: http://graphql.org/
[express]: http://expressjs.com/
[musicbrainz]: https://musicbrainz.org/
[graphql]: http://graphql.org/
[express-graphql]: https://www.npmjs.com/package/express-graphql
[dotenv]: https://www.npmjs.com/package/dotenv
[debug]: https://www.npmjs.com/package/debug
[GraphiQL]: https://github.com/graphql/graphiql
[Relay]: https://facebook.github.io/relay/
[graphiql]: https://github.com/graphql/graphiql
[graphql-js]: https://github.com/graphql/graphql-js
[relay]: https://facebook.github.io/relay/
[schema]: docs/schema.md
[types]: docs/types.md
[rate limiting]: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
[mirror]: https://musicbrainz.org/doc/MusicBrainz_Server/Setup
[aliases]: http://graphql.org/learn/queries/#aliases
[schema tests]: test/_schema.js
[cors]: https://github.com/expressjs/cors

4
cli.js Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env node
import { start } from './src/index.js';
start();

255
docs/extensions/README.md Normal file
View file

@ -0,0 +1,255 @@
# Extensions
It is possible to extend the GraphBrainz schema to add integrations with
third-party services that provide more information about MusicBrainz entities.
Extensions can define new GraphQL types and use the `extend type` syntax to add
new fields to any existing GraphBrainz type, including the root query.
Several extensions are included by default, and you can install any number of
additional extensions from a package manager or
[write your own](#extension-api).
## Contents
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Loading Extensions](#loading-extensions)
- [Built-in Extensions](#built-in-extensions)
- [More Extensions](#more-extensions)
- [Extension API](#extension-api)
- [Properties](#properties)
- [name](#name)
- [description](#description)
- [extendContext](#extendcontext)
- [extendSchema](#extendschema)
- [Example](#example)
- [Extension Guidelines](#extension-guidelines)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Loading Extensions
The extensions to load are specified using the `extensions` option to the
exported `middleware()` function. Each extension must be an object conforming to
the [Extension API](#extension-api), or the path to a module to load via
`require()` that exports such an object.
If you are running GraphBrainz as a standalone server, you may specify
extensions via the `GRAPHBRAINZ_EXTENSIONS` environment variable, which will be
parsed as a JSON array. For example:
```console
$ export GRAPHBRAINZ_EXTENSIONS='["graphbrainz/extensions/fanart-tv"]'
$ graphbrainz
```
Note that some extensions may require additional configuration via extra options
or environment variables. Check the documentation for each extension you use.
The default extensions configuration looks like this:
```js
middleware({
extensions: [
'graphbrainz/extensions/cover-art-archive',
'graphbrainz/extensions/fanart-tv',
'graphbrainz/extensions/mediawiki',
'graphbrainz/extensions/the-audio-db',
],
});
```
## Built-in Extensions
The following extensions are included with GraphBrainz and loaded by default.
See their respective documentation pages for schema info and config options.
- [Cover Art Archive](./cover-art-archive.md): Retrieve cover art images for
releases from the Cover Art Archive.
- [fanart.tv](./fanart-tv.md): Retrieve high quality artwork for artists,
releases, and labels from fanart.tv.
- [MediaWiki](./mediawiki.md): Retrieve information from MediaWiki image pages,
like the actual image file URL and EXIF metadata.
- [TheAudioDB](./the-audio-db.md): Retrieve images and information about
artists, releases, and recordings from TheAudioDB.com.
## More Extensions
The following extensions are published separately, but can easily be added to
GraphBrainz by installing them:
- [Last.fm](https://github.com/exogen/graphbrainz-extension-lastfm): Retrieve
artist, release, and recording information from
[Last.fm](https://www.last.fm/).
- [Discogs](https://github.com/exogen/graphbrainz-extension-discogs): Retrieve
artist, label, release, and release group information from
[Discogs](https://www.discogs.com/).
- [Spotify](https://github.com/exogen/graphbrainz-extension-spotify): Retrieve
artist, release, and recording information from
[Spotify](https://www.spotify.com/).
## Extension API
The core idea behind extensions comes from the [schema stitching][] feature from
[graphql-tools][], although GraphBrainz does not currently use the exact
technique documented there. Instead, we call `parse` and `extendSchema` from
[GraphQL.js][], followed by [addResolversToSchema][].
Extensions must export an object shaped like so:
```js
type Extension = {
name: string,
description?: string,
extendContext?: (context: Context, options: Options) => Context,
extendSchema?:
| { schemas: Array<string | DocumentNode>, resolvers: ResolverMap }
| ((schema: GraphQLSchema, options: Options) => GraphQLSchema),
};
```
### Properties
#### name
The name of the extension.
#### description
A description of the functionality that the extension provides.
#### extendContext
An optional function that accepts a base context object (the `context` argument
available to resolver functions) and returns a new context object. Extensions
that access third-party APIs should add any API client instances they need here.
The recommended way is to create a loader with [dataloader][] and add it onto
`context.loaders`.
#### extendSchema
An optional object or function to extend the GraphBrainz schema.
If it is an object, it should have a `schemas` array and a `resolvers` object.
Each schema must be a string (containing type definitions in GraphQL schema
language) or a `DocumentNode` (if the type definitions have already been
parsed). The `resolvers` object should contain a mapping of type fields to new
resolver functions for those fields. See [addResolversToSchema][].
If it is a function, it should accept `schema` and `options` arguments and
return a new schema. Use this if youd like to perform custom schema extension
logic. This may be necessary if you already have a `GraphQLSchema` instance and
want to use [mergeSchemas][], for example. In most cases, you should keep it
simple and use the object form.
### Example
```js
module.exports = {
name: 'Hello World',
description: 'A simple example extension.',
extendSchema: {
schemas: [
`
extend type Query {
helloWorld: String!
}
`,
],
resolvers: {
Query: {
helloWorld: {
resolve: () => 'It worked!',
},
},
},
},
};
```
This will allow the following query to be made:
```graphql
{
helloWorld
}
```
See the code for the [built-in extensions][] for more examples.
## Extension Guidelines
Extensions can load and resolve data in any manner they please, and you can
write them in any way that conforms to the API. But if you want an extra feather
in your cap, there are a few guidelines you should follow in order to maintain
consistency with GraphBrainz and the built-in extensions. Here are some tips for
writing a good extension:
- If you need to make HTTP requests, using a [Client][] subclass will get you
rate limiting, error handling, retrying, and a Promise-based API for free.
- Default to following the rate limiting rules of any APIs you use. If there are
no guidelines on rate limiting, consider playing nice anyway and limiting your
client to around 1 to 10 requests per second.
- Use a [DataLoader][dataloader] instance to batch and cache requests. Even if
the data source doesnt support batching, DataLoader will help by deduping
in-flight requests for the same key, preventing unnecessary requests.
- Use a configurable cache and make sure you arent caching everything
indefinitely by accident. The `cacheMap` option to DataLoader is a good place
to put it.
- Get as much configuration from environment variables as possible so that users
can just run the standalone server instead of writing any code. If you need
more complex configuration, use a single field on the `options` object as a
namespace for your extensions options.
- Dont hesitate to rename fields returned by third-party APIs when translating
them to the GraphQL schema. Consistency with GraphQL conventions and the
GraphBrainz schema is more desirable than consistency with the original API
being used. Some general rules:
- Match type names to the service theyre coming from (e.g. many services use
the words “album” and “track” and the type names should reflect that), but
match scalar field names to their MusicBrainz equivalents when possible
(e.g. `name` for artists but `title` for releases and recordings).
- Use camel case naming and capitalize acronyms (unless they are the only
word), e.g. `id`, `url`, `artistID`, `pageURL`.
- If its ambiguous whether a field refers to an object/list vs. a scalar
summary of an object/list, consider clarifying the field name, e.g. `user`
`userID`, `members``memberCount`.
- Dont include fields that are already available in MusicBrainz (unless its
possible to retrieve an entity that isnt in MusicBrainz). Only include
whats relevant and useful.
- Add descriptions for everything: types, fields, arguments, enum values, etc.
 with Markdown links wherever theyd be helpful.
- When extending the built-in types, prefer adding a single object field that
serves as a namespace rather than adding many fields. That way its more
obvious that the data source isnt MusicBrainz itself, and youre less likely
to conflict with new MusicBrainz fields in the future.
- Prefer using a [Relay][]-compliant schema for lists of objects that (1) have
their own IDs and (2) are likely to be paginated. Feel free to add a `nodes`
shortcut field to the Connection type (for users who want to skip over
`edges`).
- If you publish your extension, consider prefixing the package name with
`graphbrainz-extension-` and having the default export of its `main` entry
point be the extension object. That way, using it is as simple as adding the
package name to the list of extensions.
- Consider using [graphql-markdown][] to document the schema created by your
extension; this will match how GraphBrainz itself is documented. You can use
the [diffSchema][] function to document only the schema updates, see
[scripts/build-extension-docs.js][build-extension-docs] for how this is done
with the built-in extensions.
[graphql-tools]: http://dev.apollodata.com/tools/graphql-tools/index.html
[schema stitching]:
http://dev.apollodata.com/tools/graphql-tools/schema-stitching.html
[mergeschemas]:
http://dev.apollodata.com/tools/graphql-tools/schema-stitching.html#mergeSchemas
[dataloader]: https://github.com/facebook/dataloader
[built-in extensions]: ../../src/extensions
[client]: ../../src/api/client.js
[graphql-markdown]: https://github.com/exogen/graphql-markdown
[diffschema]:
https://github.com/exogen/graphql-markdown#diffschemaoldschema-object-newschema-object-options-object
[build-extension-docs]: ../../scripts/build-extension-docs.js
[relay]: https://facebook.github.io/relay/
[graphql.js]: http://graphql.org/graphql-js/
[addresolverstoschema]:
http://dev.apollodata.com/tools/graphql-tools/resolvers.html#addResolversToSchema

View file

@ -0,0 +1,382 @@
# Extension: Cover Art Archive
Retrieve cover art images for releases from the [Cover Art Archive](https://coverartarchive.org/).
This extension uses its own cache, separate from the MusicBrainz loader cache.
## Configuration
This extension can be configured using environment variables:
* **`COVER_ART_ARCHIVE_BASE_URL`**: The base URL at which to access the Cover
Art Archive API. Defaults to `http://coverartarchive.org/`.
* **`COVER_ART_ARCHIVE_CACHE_SIZE`**: The number of items to keep in the cache.
Defaults to `GRAPHBRAINZ_CACHE_SIZE` if defined, or `8192`.
* **`COVER_ART_ARCHIVE_CACHE_TTL`**: The number of seconds to keep items in the
cache. Defaults to `GRAPHBRAINZ_CACHE_TTL` if defined, or `86400000` (one day).
<!-- START graphql-markdown -->
## Schema Types
<details>
<summary><strong>Table of Contents</strong></summary>
* [Objects](#objects)
* [CoverArtArchiveImage](#coverartarchiveimage)
* [CoverArtArchiveImageThumbnails](#coverartarchiveimagethumbnails)
* [CoverArtArchiveRelease](#coverartarchiverelease)
* [Release](#release)
* [ReleaseGroup](#releasegroup)
* [Enums](#enums)
* [CoverArtArchiveImageSize](#coverartarchiveimagesize)
</details>
### Objects
#### CoverArtArchiveImage
An individual piece of album artwork from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>fileID</strong></td>
<td valign="top"><a href="../types.md#string">String</a>!</td>
<td>
The Internet Archives internal file ID for the image.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>image</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a>!</td>
<td>
The URL at which the image can be found.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>thumbnails</strong></td>
<td valign="top"><a href="#coverartarchiveimagethumbnails">CoverArtArchiveImageThumbnails</a>!</td>
<td>
A set of thumbnails for the image.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>front</strong></td>
<td valign="top"><a href="../types.md#boolean">Boolean</a>!</td>
<td>
Whether this image depicts the “main front” of the release.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>back</strong></td>
<td valign="top"><a href="../types.md#boolean">Boolean</a>!</td>
<td>
Whether this image depicts the “main back” of the release.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>types</strong></td>
<td valign="top">[<a href="../types.md#string">String</a>]!</td>
<td>
A list of [image types](https://musicbrainz.org/doc/Cover_Art/Types)
describing what part(s) of the release the image includes.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>edit</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The MusicBrainz edit ID.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>approved</strong></td>
<td valign="top"><a href="../types.md#boolean">Boolean</a></td>
<td>
Whether the image was approved by the MusicBrainz edit system.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>comment</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A free-text comment left for the image.
</td>
</tr>
</tbody>
</table>
#### CoverArtArchiveImageThumbnails
URLs for thumbnails of different sizes for a particular piece of cover art.
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>small</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of a small version of the cover art, where the maximum dimension is
250px.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>large</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of a large version of the cover art, where the maximum dimension is
500px.
</td>
</tr>
</tbody>
</table>
#### CoverArtArchiveRelease
An object containing a list of the cover art images for a release obtained
from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive),
as well as a summary of what artwork is available.
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>front</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of an image depicting the album cover or “main front” of the release,
i.e. the front of the packaging of the audio recording (or in the case of a
digital release, the image associated with it in a digital media store).
In the MusicBrainz schema, this field is a Boolean value indicating the
presence of a front image, whereas here the value is the URL for the image
itself if one exists. You can check for null if you just want to determine
the presence of an image.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#coverartarchiveimagesize">CoverArtArchiveImageSize</a></td>
<td>
The size of the image to retrieve. By default, the returned image will
have its full original dimensions, but certain thumbnail sizes may be
retrieved as well.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>back</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of an image depicting the “main back” of the release, i.e. the back
of the packaging of the audio recording.
In the MusicBrainz schema, this field is a Boolean value indicating the
presence of a back image, whereas here the value is the URL for the image
itself. You can check for null if you just want to determine the presence of
an image.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#coverartarchiveimagesize">CoverArtArchiveImageSize</a></td>
<td>
The size of the image to retrieve. By default, the returned image will
have its full original dimensions, but certain thumbnail sizes may be
retrieved as well.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>images</strong></td>
<td valign="top">[<a href="#coverartarchiveimage">CoverArtArchiveImage</a>]!</td>
<td>
A list of images depicting the different sides and surfaces of a releases
media and packaging.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>artwork</strong></td>
<td valign="top"><a href="../types.md#boolean">Boolean</a>!</td>
<td>
Whether there is artwork present for this release.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>count</strong></td>
<td valign="top"><a href="../types.md#int">Int</a>!</td>
<td>
The number of artwork images present for this release.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>release</strong></td>
<td valign="top"><a href="#release">Release</a></td>
<td>
The particular release shown in the returned cover art.
</td>
</tr>
</tbody>
</table>
#### Release
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>coverArtArchive</strong></td>
<td valign="top"><a href="#coverartarchiverelease">CoverArtArchiveRelease</a></td>
<td>
An object containing a list and summary of the cover art images that are
present for this release from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).
This field is provided by the Cover Art Archive extension.
</td>
</tr>
</tbody>
</table>
#### ReleaseGroup
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>coverArtArchive</strong></td>
<td valign="top"><a href="#coverartarchiverelease">CoverArtArchiveRelease</a></td>
<td>
The cover art for a release in the release group, obtained from the
[Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive). A
release in the release group will be chosen as representative of the release
group.
This field is provided by the Cover Art Archive extension.
</td>
</tr>
</tbody>
</table>
### Enums
#### CoverArtArchiveImageSize
The image sizes that may be requested at the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).
<table>
<thead>
<th align="left">Value</th>
<th align="left">Description</th>
</thead>
<tbody>
<tr>
<td valign="top"><strong>SMALL</strong></td>
<td>
A maximum dimension of 250px.
</td>
</tr>
<tr>
<td valign="top"><strong>LARGE</strong></td>
<td>
A maximum dimension of 500px.
</td>
</tr>
<tr>
<td valign="top"><strong>FULL</strong></td>
<td>
The images original dimensions, with no maximum.
</td>
</tr>
</tbody>
</table>
<!-- END graphql-markdown -->

View file

@ -0,0 +1,479 @@
# Extension: fanart.tv
Retrieve high quality artwork for artists, releases, and labels from
[fanart.tv](https://fanart.tv/).
This extension uses its own cache, separate from the MusicBrainz loader cache.
## Configuration
This extension can be configured using environment variables:
* **`FANART_API_KEY`**: The fanart.tv API key to use. This is required for any
fields added by the extension to successfully resolve.
* **`FANART_BASE_URL`**: The base URL at which to access the
fanart.tv API. Defaults to `http://webservice.fanart.tv/v3/`.
* **`FANART_CACHE_SIZE`**: The number of items to keep in the cache.
Defaults to `GRAPHBRAINZ_CACHE_SIZE` if defined, or `8192`.
* **`FANART_CACHE_TTL`**: The number of seconds to keep items in the
cache. Defaults to `GRAPHBRAINZ_CACHE_TTL` if defined, or `86400000` (one day).
<!-- START graphql-markdown -->
## Schema Types
<details>
<summary><strong>Table of Contents</strong></summary>
* [Objects](#objects)
* [Artist](#artist)
* [FanArtAlbum](#fanartalbum)
* [FanArtArtist](#fanartartist)
* [FanArtDiscImage](#fanartdiscimage)
* [FanArtImage](#fanartimage)
* [FanArtLabel](#fanartlabel)
* [FanArtLabelImage](#fanartlabelimage)
* [Label](#label)
* [ReleaseGroup](#releasegroup)
* [Enums](#enums)
* [FanArtImageSize](#fanartimagesize)
</details>
### Objects
#### Artist
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>fanArt</strong></td>
<td valign="top"><a href="#fanartartist">FanArtArtist</a></td>
<td>
Images of the artist from [fanart.tv](https://fanart.tv/).
This field is provided by the fanart.tv extension.
</td>
</tr>
</tbody>
</table>
#### FanArtAlbum
An object containing lists of the different types of release group images from
[fanart.tv](https://fanart.tv/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>albumCovers</strong></td>
<td valign="top">[<a href="#fanartimage">FanArtImage</a>]</td>
<td>
A list of 1000x1000 JPG images of the cover artwork of the release group.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>discImages</strong></td>
<td valign="top">[<a href="#fanartdiscimage">FanArtDiscImage</a>]</td>
<td>
A list of 1000x1000 PNG images of the physical disc media for the release
group, with transparent backgrounds.
</td>
</tr>
</tbody>
</table>
#### FanArtArtist
An object containing lists of the different types of artist images from
[fanart.tv](https://fanart.tv/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>backgrounds</strong></td>
<td valign="top">[<a href="#fanartimage">FanArtImage</a>]</td>
<td>
A list of 1920x1080 JPG images picturing the artist, suitable for use as
backgrounds.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>banners</strong></td>
<td valign="top">[<a href="#fanartimage">FanArtImage</a>]</td>
<td>
A list of 1000x185 JPG images containing the artist and their logo or name.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>logos</strong></td>
<td valign="top">[<a href="#fanartimage">FanArtImage</a>]</td>
<td>
A list of 400x155 PNG images containing the artists logo or name, with
transparent backgrounds.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>logosHD</strong></td>
<td valign="top">[<a href="#fanartimage">FanArtImage</a>]</td>
<td>
A list of 800x310 PNG images containing the artists logo or name, with
transparent backgrounds.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>thumbnails</strong></td>
<td valign="top">[<a href="#fanartimage">FanArtImage</a>]</td>
<td>
A list of 1000x1000 JPG thumbnail images picturing the artist (usually
containing every member of a band).
</td>
</tr>
</tbody>
</table>
#### FanArtDiscImage
A disc image from [fanart.tv](https://fanart.tv/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>imageID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
The ID of the image on fanart.tv.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>url</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of the image.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#fanartimagesize">FanArtImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>likeCount</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The number of likes the image has received by fanart.tv users.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>discNumber</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The disc number.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>size</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The width and height of the (square) disc image.
</td>
</tr>
</tbody>
</table>
#### FanArtImage
A single image from [fanart.tv](https://fanart.tv/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>imageID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
The ID of the image on fanart.tv.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>url</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of the image.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#fanartimagesize">FanArtImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>likeCount</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The number of likes the image has received by fanart.tv users.
</td>
</tr>
</tbody>
</table>
#### FanArtLabel
An object containing lists of the different types of label images from
[fanart.tv](https://fanart.tv/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>logos</strong></td>
<td valign="top">[<a href="#fanartlabelimage">FanArtLabelImage</a>]</td>
<td>
A list of 400x270 PNG images containing the labels logo. There will
usually be a black version, a color version, and a white version, all with
transparent backgrounds.
</td>
</tr>
</tbody>
</table>
#### FanArtLabelImage
A music label image from [fanart.tv](https://fanart.tv/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>imageID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
The ID of the image on fanart.tv.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>url</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of the image.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#fanartimagesize">FanArtImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>likeCount</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The number of likes the image has received by fanart.tv users.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>color</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The type of color content in the image (usually “white” or “colour”).
</td>
</tr>
</tbody>
</table>
#### Label
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>fanArt</strong></td>
<td valign="top"><a href="#fanartlabel">FanArtLabel</a></td>
<td>
Images of the label from [fanart.tv](https://fanart.tv/).
This field is provided by the fanart.tv extension.
</td>
</tr>
</tbody>
</table>
#### ReleaseGroup
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>fanArt</strong></td>
<td valign="top"><a href="#fanartalbum">FanArtAlbum</a></td>
<td>
Images of the release group from [fanart.tv](https://fanart.tv/).
This field is provided by the fanart.tv extension.
</td>
</tr>
</tbody>
</table>
### Enums
#### FanArtImageSize
The image sizes that may be requested at [fanart.tv](https://fanart.tv/).
<table>
<thead>
<th align="left">Value</th>
<th align="left">Description</th>
</thead>
<tbody>
<tr>
<td valign="top"><strong>FULL</strong></td>
<td>
The images full original dimensions.
</td>
</tr>
<tr>
<td valign="top"><strong>PREVIEW</strong></td>
<td>
A maximum dimension of 200px.
</td>
</tr>
</tbody>
</table>
<!-- END graphql-markdown -->

View file

@ -0,0 +1,411 @@
# Extension: MediaWiki
Retrieve information from MediaWiki image pages, like the actual image file URL
and EXIF metadata.
On entities with [URL relationship types][relationships] that represent images,
this extension will find those URLs that appear to be MediaWiki image pages, and
use the [MediaWiki API][] to fetch information about the image. This information
will include the actual file URL, so you can use it as the `src` in an `<img>`
tag (for example).
MediaWiki image URLs are assumed to be those with a path that starts with
`/wiki/Image:` or `/wiki/File:`.
This extension uses its own cache, separate from the MusicBrainz loader cache.
## Configuration
This extension can be configured using environment variables:
* **`MEDIAWIKI_CACHE_SIZE`**: The number of items to keep in the cache.
Defaults to `GRAPHBRAINZ_CACHE_SIZE` if defined, or `8192`.
* **`MEDIAWIKI_CACHE_TTL`**: The number of seconds to keep items in the
cache. Defaults to `GRAPHBRAINZ_CACHE_TTL` if defined, or `86400000` (one day).
[relationships]: https://musicbrainz.org/relationships
[MediaWiki API]: https://www.mediawiki.org/wiki/API:Main_page
<!-- START graphql-markdown -->
## Schema Types
<details>
<summary><strong>Table of Contents</strong></summary>
* [Objects](#objects)
* [Artist](#artist)
* [Instrument](#instrument)
* [Label](#label)
* [MediaWikiImage](#mediawikiimage)
* [MediaWikiImageMetadata](#mediawikiimagemetadata)
* [Place](#place)
</details>
### Objects
#### Artist
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>mediaWikiImages</strong></td>
<td valign="top">[<a href="#mediawikiimage">MediaWikiImage</a>]!</td>
<td>
Artist images found at MediaWiki URLs in the artists URL relationships.
Defaults to URL relationships with the type “image”.
This field is provided by the MediaWiki extension.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">type</td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The type of URL relationship that will be selected to find images. See
the possible [Artist-URL relationship types](https://musicbrainz.org/relationships/artist-url).
</td>
</tr>
</tbody>
</table>
#### Instrument
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>mediaWikiImages</strong></td>
<td valign="top">[<a href="#mediawikiimage">MediaWikiImage</a>]!</td>
<td>
Instrument images found at MediaWiki URLs in the instruments URL
relationships. Defaults to URL relationships with the type “image”.
This field is provided by the MediaWiki extension.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">type</td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The type of URL relationship that will be selected to find images. See the
possible [Instrument-URL relationship types](https://musicbrainz.org/relationships/instrument-url).
</td>
</tr>
</tbody>
</table>
#### Label
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>mediaWikiImages</strong></td>
<td valign="top">[<a href="#mediawikiimage">MediaWikiImage</a>]!</td>
<td>
Label images found at MediaWiki URLs in the labels URL relationships.
Defaults to URL relationships with the type “logo”.
This field is provided by the MediaWiki extension.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">type</td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The type of URL relationship that will be selected to find images. See the
possible [Label-URL relationship types](https://musicbrainz.org/relationships/label-url).
</td>
</tr>
</tbody>
</table>
#### MediaWikiImage
An object describing various properties of an image stored on a MediaWiki
server. The information comes the [MediaWiki imageinfo API](https://www.mediawiki.org/wiki/API:Imageinfo).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>url</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a>!</td>
<td>
The URL of the actual image file.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>descriptionURL</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL of the wiki page describing the image.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>user</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The user who uploaded the file.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>size</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The size of the file in bytes.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>width</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The pixel width of the image.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>height</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The pixel height of the image.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>canonicalTitle</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The canonical title of the file.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>objectName</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The image title, brief description, or file name.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>descriptionHTML</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A description of the image, potentially containing HTML.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>originalDateTimeHTML</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The original date of creation of the image. May be a description rather than
a parseable timestamp, and may contain HTML.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>categories</strong></td>
<td valign="top">[<a href="../types.md#string">String</a>]!</td>
<td>
A list of the categories of the image.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>artistHTML</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The name of the image author, potentially containing HTML.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>creditHTML</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The source of the image, potentially containing HTML.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>licenseShortName</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A short human-readable license name.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>licenseURL</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
A web address where the license is described.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>metadata</strong></td>
<td valign="top">[<a href="#mediawikiimagemetadata">MediaWikiImageMetadata</a>]!</td>
<td>
The full list of values in the `extmetadata` field.
</td>
</tr>
</tbody>
</table>
#### MediaWikiImageMetadata
An entry in the `extmetadata` field of a MediaWiki image file.
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>name</strong></td>
<td valign="top"><a href="../types.md#string">String</a>!</td>
<td>
The name of the metadata field.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>value</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The value of the metadata field. All values will be converted to strings.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>source</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The source of the value.
</td>
</tr>
</tbody>
</table>
#### Place
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>mediaWikiImages</strong></td>
<td valign="top">[<a href="#mediawikiimage">MediaWikiImage</a>]!</td>
<td>
Place images found at MediaWiki URLs in the places URL relationships.
Defaults to URL relationships with the type “image”.
This field is provided by the MediaWiki extension.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">type</td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The type of URL relationship that will be selected to find images. See the
possible [Place-URL relationship types](https://musicbrainz.org/relationships/place-url).
</td>
</tr>
</tbody>
</table>
<!-- END graphql-markdown -->

View file

@ -0,0 +1,776 @@
# Extension: TheAudioDB
Retrieve images and information about artists, releases, and recordings from
[TheAudioDB.com](http://www.theaudiodb.com/).
This extension uses its own cache, separate from the MusicBrainz loader cache.
## Configuration
This extension can be configured using environment variables:
* **`THEAUDIODB_API_KEY`**: TheAudioDB API key to use. This is required for any
fields added by the extension to successfully resolve.
* **`THEAUDIODB_BASE_URL`**: The base URL at which to access TheAudioDB API.
Defaults to `http://www.theaudiodb.com/api/v1/json/`.
* **`THEAUDIODB_CACHE_SIZE`**: The number of items to keep in the cache.
Defaults to `GRAPHBRAINZ_CACHE_SIZE` if defined, or `8192`.
* **`THEAUDIODB_CACHE_TTL`**: The number of seconds to keep items in the
cache. Defaults to `GRAPHBRAINZ_CACHE_TTL` if defined, or `86400000` (one day).
<!-- START graphql-markdown -->
## Schema Types
<details>
<summary><strong>Table of Contents</strong></summary>
* [Objects](#objects)
* [Artist](#artist)
* [Recording](#recording)
* [ReleaseGroup](#releasegroup)
* [TheAudioDBAlbum](#theaudiodbalbum)
* [TheAudioDBArtist](#theaudiodbartist)
* [TheAudioDBMusicVideo](#theaudiodbmusicvideo)
* [TheAudioDBTrack](#theaudiodbtrack)
* [Enums](#enums)
* [TheAudioDBImageSize](#theaudiodbimagesize)
</details>
### Objects
#### Artist
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>theAudioDB</strong></td>
<td valign="top"><a href="#theaudiodbartist">TheAudioDBArtist</a></td>
<td>
Data about the artist from [TheAudioDB](http://www.theaudiodb.com/), a good
source of biographical information and images.
This field is provided by TheAudioDB extension.
</td>
</tr>
</tbody>
</table>
#### Recording
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>theAudioDB</strong></td>
<td valign="top"><a href="#theaudiodbtrack">TheAudioDBTrack</a></td>
<td>
Data about the recording from [TheAudioDB](http://www.theaudiodb.com/).
This field is provided by TheAudioDB extension.
</td>
</tr>
</tbody>
</table>
#### ReleaseGroup
:small_blue_diamond: *This type has been extended. See the [base schema](../types.md)
for a description and additional fields.*
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>theAudioDB</strong></td>
<td valign="top"><a href="#theaudiodbalbum">TheAudioDBAlbum</a></td>
<td>
Data about the release group from [TheAudioDB](http://www.theaudiodb.com/),
a good source of descriptive information, reviews, and images.
This field is provided by TheAudioDB extension.
</td>
</tr>
</tbody>
</table>
#### TheAudioDBAlbum
An album on [TheAudioDB](http://www.theaudiodb.com/) corresponding with a
MusicBrainz Release Group.
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>albumID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
TheAudioDB ID of the album.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>artistID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
TheAudioDB ID of the artist who released the album.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>description</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A description of the album, often available in several languages.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">lang</td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The two-letter code for the language in which to retrieve the biography.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>review</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A review of the album.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>salesCount</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The worldwide sales figure.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>score</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The albums rating as determined by user votes, out of 10.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>scoreVotes</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The number of users who voted to determine the albums score.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>discImage</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
An image of the physical disc media for the album.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>spineImage</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
An image of the spine of the album packaging.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>frontImage</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
An image of the front of the album packaging.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>backImage</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
An image of the back of the album packaging.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>genre</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical genre of the album (e.g. “Alternative Rock”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>mood</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical mood of the album (e.g. “Sad”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>style</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical style of the album (e.g. “Rock/Pop”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>speed</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A rough description of the primary musical speed of the album (e.g. “Medium”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>theme</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical theme of the album (e.g. “In Love”).
</td>
</tr>
</tbody>
</table>
#### TheAudioDBArtist
An artist on [TheAudioDB](http://www.theaudiodb.com/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>artistID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
TheAudioDB ID of the artist.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>biography</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A biography of the artist, often available in several languages.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">lang</td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The two-letter code for the language in which to retrieve the biography.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>memberCount</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The number of members in the musical group, if applicable.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>banner</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
A 1000x185 JPG banner image containing the artist and their logo or name.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>fanArt</strong></td>
<td valign="top">[<a href="../types.md#urlstring">URLString</a>]!</td>
<td>
A list of 1280x720 or 1920x1080 JPG images depicting the artist.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the images to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>logo</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
A 400x155 PNG image containing the artists logo or name, with a transparent
background.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>thumbnail</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
A 1000x1000 JPG thumbnail image picturing the artist (usually containing
every member of a band).
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>genre</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical genre of the artist (e.g. “Alternative Rock”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>mood</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical mood of the artist (e.g. “Sad”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>style</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical style of the artist (e.g. “Rock/Pop”).
</td>
</tr>
</tbody>
</table>
#### TheAudioDBMusicVideo
Details of a music video associated with a track on [TheAudioDB](http://www.theaudiodb.com/).
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>url</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
The URL where the music video can be found.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>companyName</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The video production company of the music video.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>directorName</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The director of the music video.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>screenshots</strong></td>
<td valign="top">[<a href="../types.md#urlstring">URLString</a>]!</td>
<td>
A list of still images from the music video.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the images to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>viewCount</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The number of views the video has received at the given URL. This will rarely
be up to date, so use cautiously.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>likeCount</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The number of likes the video has received at the given URL. This will rarely
be up to date, so use cautiously.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>dislikeCount</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The number of dislikes the video has received at the given URL. This will
rarely be up to date, so use cautiously.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>commentCount</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The number of comments the video has received at the given URL. This will
rarely be up to date, so use cautiously.
</td>
</tr>
</tbody>
</table>
#### TheAudioDBTrack
A track on [TheAudioDB](http://www.theaudiodb.com/) corresponding with a
MusicBrainz Recording.
<table>
<thead>
<tr>
<th align="left">Field</th>
<th align="right">Argument</th>
<th align="left">Type</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" valign="top"><strong>trackID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
TheAudioDB ID of the track.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>albumID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
TheAudioDB ID of the album on which the track appears.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>artistID</strong></td>
<td valign="top"><a href="../types.md#id">ID</a></td>
<td>
TheAudioDB ID of the artist who released the track.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>description</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
A description of the track.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">lang</td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The two-letter code for the language in which to retrieve the description.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>thumbnail</strong></td>
<td valign="top"><a href="../types.md#urlstring">URLString</a></td>
<td>
A thumbnail image for the track.
</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">size</td>
<td valign="top"><a href="#theaudiodbimagesize">TheAudioDBImageSize</a></td>
<td>
The size of the image to retrieve.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>score</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The tracks rating as determined by user votes, out of 10.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>scoreVotes</strong></td>
<td valign="top"><a href="../types.md#float">Float</a></td>
<td>
The number of users who voted to determine the albums score.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>trackNumber</strong></td>
<td valign="top"><a href="../types.md#int">Int</a></td>
<td>
The track number of the song on the album.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>musicVideo</strong></td>
<td valign="top"><a href="#theaudiodbmusicvideo">TheAudioDBMusicVideo</a></td>
<td>
The official music video for the track.
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>genre</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical genre of the track (e.g. “Alternative Rock”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>mood</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical mood of the track (e.g. “Sad”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>style</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical style of the track (e.g. “Rock/Pop”).
</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong>theme</strong></td>
<td valign="top"><a href="../types.md#string">String</a></td>
<td>
The primary musical theme of the track (e.g. “In Love”).
</td>
</tr>
</tbody>
</table>
### Enums
#### TheAudioDBImageSize
The image sizes that may be requested at [TheAudioDB](http://www.theaudiodb.com/).
<table>
<thead>
<th align="left">Value</th>
<th align="left">Description</th>
</thead>
<tbody>
<tr>
<td valign="top"><strong>FULL</strong></td>
<td>
The images full original dimensions.
</td>
</tr>
<tr>
<td valign="top"><strong>PREVIEW</strong></td>
<td>
A maximum dimension of 200px.
</td>
</tr>
</tbody>
</table>
<!-- END graphql-markdown -->

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export { default } from '../src/extensions/cover-art-archive/index.js';

1
extensions/fanart-tv.js Normal file
View file

@ -0,0 +1 @@
export { default } from '../src/extensions/fanart-tv/index.js';

1
extensions/mediawiki.js Normal file
View file

@ -0,0 +1 @@
export { default } from '../src/extensions/mediawiki/index.js';

View file

@ -0,0 +1 @@
export { default } from '../src/extensions/the-audio-db/index.js';

View file

@ -1,41 +1,7 @@
{
"name": "graphbrainz",
"version": "2.5.0",
"description": "An Express server and middleware for querying the MusicBrainz API using GraphQL.",
"main": "lib/index.js",
"bin": "lib/index.js",
"files": [
"lib",
"scripts",
"Procfile",
"schema.json",
"yarn.lock"
],
"engines": {
"node": ">=4.3.0",
"npm": ">=3.8.0"
},
"scripts": {
"build": "npm run build:lib && npm run update-schema && npm run build:docs",
"build:docs": "npm run build:docs:readme && npm run build:docs:schema && npm run build:docs:types",
"build:docs:readme": "doctoc --title \"## Contents\" README.md",
"build:docs:schema": "printf '# GraphQL Schema\\n\\n%s\n' \"$(npm run -s print-schema:md)\" > docs/schema.md",
"build:docs:types": "babel-node scripts/render-types.js > docs/types.md",
"build:lib": "babel --out-dir lib src",
"clean": "npm run clean:lib",
"clean:lib": "rm -rf lib",
"check": "npm run lint && npm run test",
"deploy": "./scripts/deploy.sh",
"lint": "standard --verbose | snazzy",
"prepublish": "npm run clean && npm run check && npm run build",
"print-schema": "babel-node scripts/print-schema.js",
"print-schema:json": "npm run print-schema -- --json",
"print-schema:md": "printf '```graphql\\n%s\\n```' \"$(npm run -s print-schema)\"",
"start": "node lib/index.js",
"start:dev": "nodemon --exec babel-node src/index.js",
"test": "mocha --compilers js:babel-register",
"update-schema": "npm run -s print-schema:json > schema.json"
},
"version": "9.0.0",
"description": "A GraphQL schema, Express server, and middleware for querying the MusicBrainz.",
"keywords": [
"musicbrainz",
"graphql",
@ -48,46 +14,107 @@
"author": {
"name": "Brian Beck",
"email": "exogen@gmail.com",
"url": "http://brianbeck.com/"
"url": "https://brianbeck.com/"
},
"repository": {
"type": "git",
"url": "https://github.com/exogen/graphbrainz.git"
},
"license": "MIT",
"engines": {
"node": ">=12.18.0",
"npm": ">=6.0.0"
},
"type": "module",
"main": "./src/index.js",
"exports": {
".": "./src/index.js",
"./extensions/cover-art-archive": "./extensions/cover-art-archive.js",
"./extensions/fanart-tv": "./extensions/fanart-tv.js",
"./extensions/mediawiki": "./extensions/mediawiki.js",
"./extensions/the-audio-db": "./extensions/the-audio-db.js",
"./package.json": "./package.json",
"./schema.json": "./schema.json"
},
"bin": "cli.js",
"files": [
"extensions",
"src",
"cli.js",
"schema.json"
],
"scripts": {
"build": "npm run update-schema && npm run build:docs",
"build:docs": "npm run build:docs:readme && npm run build:docs:schema && npm run build:docs:types && npm run build:docs:extensions",
"build:docs:extensions": "node scripts/build-extension-docs.js",
"build:docs:readme": "doctoc --notitle README.md docs/extensions/README.md",
"build:docs:schema": "printf '# GraphQL Schema\\n\\n%s\n' \"$(npm run -s print-schema:md)\" > docs/schema.md",
"build:docs:types": "graphql-markdown ./schema.json --no-title --update-file docs/types.md",
"deploy": "./scripts/deploy.sh",
"format": "npm run lint:fix",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"preversion": "npm run update-schema && npm run build:docs && git add schema.json docs",
"print-schema": "node scripts/print-schema.js",
"print-schema:json": "npm run print-schema -- --json",
"print-schema:md": "printf '```graphql\\n%s\\n```' \"$(npm run -s print-schema)\"",
"start": "node cli.js",
"start:dev": "nodemon cli.js",
"test": "npm run lint && npm run test:coverage",
"test:coverage": "c8 --all npm run test:only",
"test:only": "cross-env NOCK_MODE=play ava",
"test:record": "cross-env NOCK_MODE=record ava --concurrency=1 --timeout=1m",
"test:record-new": "cross-env NOCK_MODE=cache ava --concurrency=1 --timeout=1m",
"test:watch": "npm run test:only -- --watch",
"update-schema": "npm run -s print-schema:json > schema.json"
},
"ava": {
"require": [
"dotenv/config"
]
},
"ava-nock": {
"fixtureDir": "fixtures",
"pathFilter": [
"(([?&]api_key=)(\\w+))|((/json/)(\\w+)(/[\\w-]+-mb\\.php))",
"$2$5*$7"
]
},
"dependencies": {
"chalk": "^1.1.3",
"compression": "^1.6.2",
"dashify": "^0.2.2",
"dataloader": "^1.2.0",
"debug": "^2.3.3",
"dotenv": "^2.0.0",
"es6-error": "^4.0.0",
"express": "^4.14.0",
"express-graphql": "^0.6.1",
"graphql": "^0.8.2",
"graphql-relay": "^0.4.4",
"lru-cache": "^4.0.1",
"pascalcase": "^0.1.1",
"qs": "^6.3.0",
"request": "^2.79.0",
"retry": "^0.10.0"
"@graphql-tools/schema": "^7.1.3",
"compression": "^1.7.3",
"cors": "^2.8.4",
"dashify": "^2.0.0",
"dataloader": "^2.0.0",
"debug": "^4.3.1",
"dotenv": "^8.2.0",
"es6-error": "^4.1.1",
"express": "^4.16.3",
"express-graphql": "^0.12.0",
"got": "^11.8.2",
"graphql": "^15.5.0",
"graphql-relay": "^0.6.0",
"lru-cache": "^6.0.0",
"pascalcase": "^1.0.0",
"read-pkg-up": "^8.0.0"
},
"devDependencies": {
"babel-cli": "^6.18.0",
"babel-eslint": "^7.1.1",
"babel-preset-es2015": "^6.18.0",
"babel-preset-stage-2": "^6.18.0",
"babel-register": "^6.18.0",
"chai": "^3.5.0",
"doctoc": "^1.2.0",
"marked": "^0.3.6",
"mocha": "^3.2.0",
"nodemon": "^1.11.0",
"snazzy": "^5.0.0",
"standard": "^8.6.0"
},
"standard": {
"parser": "babel-eslint"
"ava": "^3.15.0",
"ava-nock": "^2.1.0",
"c8": "^7.7.1",
"coveralls": "^3.0.2",
"cross-env": "^7.0.3",
"doctoc": "^2.0.0",
"eslint": "^7.24.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-promise": "^5.1.0",
"graphql-markdown": "^6.0.0",
"nodemon": "^2.0.7",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
"sinon": "^10.0.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
import path from 'path';
import { fileURLToPath } from 'url';
import GraphQL from 'graphql';
import GraphQLMarkdown from 'graphql-markdown';
import { baseSchema, createSchema } from '../src/schema.js';
const { graphql, getIntrospectionQuery } = GraphQL;
const { updateSchema, diffSchema } = GraphQLMarkdown;
async function getSchemaJSON(schema) {
const result = await graphql(schema, getIntrospectionQuery());
return result.data;
}
async function buildExtensionDocs(extensionModules) {
return Promise.all(
extensionModules.map(async (extensionName) => {
const extensionModule = await import(
`../src/extensions/${extensionName}/index.js`
);
const extension = extensionModule.default;
console.log(`Generating docs for “${extension.name}” extension...`);
const schema = createSchema(baseSchema, { extensions: [extension] });
const [baseSchemaJSON, schemaJSON] = await Promise.all([
getSchemaJSON(baseSchema),
getSchemaJSON(schema),
]);
const outputSchema = diffSchema(baseSchemaJSON, schemaJSON, {
processTypeDiff(type) {
if (type.description === undefined) {
type.description =
':small_blue_diamond: *This type has been extended. See the ' +
'[base schema](../types.md)\nfor a description and additional ' +
'fields.*';
}
return type;
},
});
const outputPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
`../docs/extensions/${extensionName}.md`
);
return updateSchema(outputPath, outputSchema, {
unknownTypeURL: '../types.md',
headingLevel: 2,
});
})
);
}
buildExtensionDocs([
'cover-art-archive',
'fanart-tv',
'mediawiki',
'the-audio-db',
])
.then((extensions) => {
console.log(`Built docs for ${extensions.length} extension(s).`);
})
.catch((err) => {
console.log('Error:', err);
});

View file

@ -7,7 +7,7 @@ RESET='\033[0m'
# Fail if the `heroku` remote isn't there.
git remote show heroku
git stash # Stash uncommitted changes.
STASH_OUTPUT=$(git stash) # Stash uncommitted changes.
git checkout -B deploy # Force branch creation/reset.
npm run build
git add -f lib # Force add ignored files.
@ -17,6 +17,9 @@ git push -f heroku deploy:master
git rm -r --cached lib # Otherwise switching branches will remove them.
git checkout - # Switch back to whatever branch we came from.
git branch -D deploy # Just to prevent someone accidentally pushing to GitHub.
git stash pop --index || true # Restore uncommitted changes, OK if none.
if [[ $STASH_OUTPUT != "No local changes"* ]]; then
git stash pop --index # Restore uncommitted changes.
fi
echo -e "\n${GREEN}✔︎ Successfully deployed.${RESET}"
heroku open || true

View file

@ -1,14 +1,16 @@
import { graphql, introspectionQuery, printSchema } from 'graphql'
import schema from '../src/schema'
import GraphQL from 'graphql';
import { baseSchema as schema } from '../src/schema.js';
if (require.main === module) {
if (process.argv[2] === '--json') {
graphql(schema, introspectionQuery).then(result => {
console.log(JSON.stringify(result, null, 2))
}).catch(err => {
console.error(err)
const { graphql, getIntrospectionQuery, printSchema } = GraphQL;
if (process.argv[2] === '--json') {
graphql(schema, getIntrospectionQuery())
.then((result) => {
console.log(JSON.stringify(result, null, 2));
})
} else {
console.log(printSchema(schema))
}
.catch((err) => {
console.error(err);
});
} else {
console.log(printSchema(schema));
}

View file

@ -1,146 +0,0 @@
import marked from 'marked'
const schema = require('../schema.json').data.__schema
// Ideally, we could just spit out the existing description Markdown everywhere
// and leave it to be rendered by whatever processes the output. But some
// Markdown renderers, including GitHub's, don't process Markdown if it's within
// an HTML tag. So in some places (like descriptions of the types themselves) we
// just output the raw description. In other places, like table cells, we need
// to output pre-rendered Markdown, otherwise GitHub won't interpret it.
marked.setOptions({
breaks: false
})
function markdown (markup) {
return marked(markup || '')
.replace(/<\/p>\s*<p>/g, '<br><br>')
.replace(/<\/?p>/g, '')
.trim()
}
function sortBy (arr, property) {
arr.sort((a, b) => {
const aValue = a[property]
const bValue = b[property]
if (aValue > bValue) return 1
if (bValue > aValue) return -1
return 0
})
}
function renderType (type) {
if (type.kind === 'NON_NULL') {
return renderType(type.ofType) + '!'
}
if (type.kind === 'LIST') {
return `[${renderType(type.ofType)}]`
}
return `[${type.name}](#${type.name.toLowerCase()})`
}
function renderObject (type, { skipTitle = false } = {}) {
if (!skipTitle) {
console.log(`\n### ${type.name}\n`)
}
if (type.description) {
console.log(`${type.description}\n`)
}
console.log('<table><thead>')
console.log(' <th align="left">Field&nbsp;/&nbsp;Argument</th>')
console.log(' <th align="left">Type</th>')
console.log(' <th align="left">Description</th>')
console.log('</thead><tbody>')
type.fields.forEach(field => {
console.log(' <tr>')
console.log(` <td valign="top"><strong>${field.name}</strong></td>`)
console.log(` <td valign="top">${markdown(renderType(field.type))}</td>`)
console.log(` <td>${markdown(field.description)}</td>`)
console.log(' </tr>')
if (field.args.length) {
field.args.forEach((arg, i) => {
console.log(' <tr>')
console.log(` <td align="right" valign="top">${arg.name}</td>`)
console.log(` <td valign="top">${markdown(renderType(arg.type))}</td>`)
console.log(` <td>${markdown(arg.description)}</td>`)
console.log(' </tr>')
})
}
})
console.log('</tbody></table>')
}
const types = schema.types.filter(type => !type.name.startsWith('__'))
const query = types.filter(type => type.name === schema.queryType.name)[0]
const objects = types.filter(type => type.kind === 'OBJECT' && type !== query)
const enums = types.filter(type => type.kind === 'ENUM').sort()
const scalars = types.filter(type => type.kind === 'SCALAR').sort()
const interfaces = types.filter(type => type.kind === 'INTERFACE').sort()
sortBy(objects, 'name')
sortBy(enums, 'name')
sortBy(scalars, 'name')
sortBy(interfaces, 'name')
console.log('# Schema Types\n')
console.log('You may also be interested in reading the [schema in GraphQL syntax](schema.md).\n')
console.log('<details><summary>**Table of Contents**</summary><p><ul>')
console.log(' <li>[Query](#query)</li>')
console.log(' <li>[Objects](#objects)<ul>')
objects.forEach(type => {
console.log(` <li>[${type.name}](#${type.name.toLowerCase()})</li>`)
})
console.log(' </ul></li>')
console.log(' <li>[Enums](#enums)<ul>')
enums.forEach(type => {
console.log(` <li>[${type.name}](#${type.name.toLowerCase()})</li>`)
})
console.log(' </ul></li>')
console.log(' <li>[Scalars](#scalars)<ul>')
scalars.forEach(type => {
console.log(` <li>[${type.name}](#${type.name.toLowerCase()})</li>`)
})
console.log(' </ul></li>')
console.log(' <li>[Interfaces](#interfaces)<ul>')
interfaces.forEach(type => {
console.log(` <li>[${type.name}](#${type.name.toLowerCase()})</li>`)
})
console.log(' </ul></li>')
console.log('</ul></p></details>')
console.log(`\n## Query ${query.name === 'Query' ? '' : '(' + query.name + ')'}`)
renderObject(query, { skipTitle: true })
console.log('\n## Objects')
objects.forEach(type => renderObject(type))
console.log('\n## Enums')
enums.forEach(type => {
console.log(`\n### ${type.name}\n`)
if (type.description) {
console.log(`${type.description}\n`)
}
console.log('<table><thead>')
console.log(' <th align="left">Value</th>')
console.log(' <th align="left">Description</th>')
console.log('</thead><tbody>')
type.enumValues.forEach(value => {
console.log(' <tr>')
console.log(` <td valign="top"><strong>${value.name}</strong></td>`)
console.log(` <td>${markdown(value.description)}</td>`)
console.log(' </tr>')
})
console.log('</tbody></table>')
})
console.log('\n## Scalars\n')
scalars.forEach(type => {
console.log(`### ${type.name}\n`)
if (type.description) {
console.log(`${type.description}\n`)
}
})
console.log('\n## Interfaces\n')
interfaces.forEach(type => renderObject(type))

View file

@ -1,206 +0,0 @@
import request from 'request'
import retry from 'retry'
import qs from 'qs'
import chalk from 'chalk'
import ExtendableError from 'es6-error'
import RateLimit from './rate-limit'
import pkg from '../package.json'
const debug = require('debug')('graphbrainz:api')
// If the `request` callback returns an error, it indicates a failure at a lower
// level than the HTTP response itself. If it's any of the following error
// codes, we should retry.
const RETRY_CODES = {
ECONNRESET: true,
ENOTFOUND: true,
ESOCKETTIMEDOUT: true,
ETIMEDOUT: true,
ECONNREFUSED: true,
EHOSTUNREACH: true,
EPIPE: true,
EAI_AGAIN: true
}
export class MusicBrainzError extends ExtendableError {
constructor (message, statusCode) {
super(message)
this.statusCode = statusCode
}
}
export default class MusicBrainz {
constructor ({
baseURL = process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/',
userAgent = `${pkg.name}/${pkg.version} ` +
`( ${pkg.homepage || pkg.author.url || pkg.author.email} )`,
timeout = 60000,
// MusicBrainz API requests are limited to an *average* of 1 req/sec.
// That means if, for example, we only need to make a few API requests to
// fulfill a query, we might as well make them all at once - as long as
// we then wait a few seconds before making more. In practice this can
// seemingly be set to about 5 requests every 5 seconds before we're
// considered to exceed the rate limit.
limit = 5,
period = 5500,
concurrency = 10,
retries = 10,
// It's OK for `retryDelayMin` to be less than one second, even 0, because
// `RateLimit` will already make sure we don't exceed the API rate limit.
// We're not doing exponential backoff because it will help with being
// rate limited, but rather to be chill in case MusicBrainz is returning
// some other error or our network is failing.
retryDelayMin = 100,
retryDelayMax = 60000,
randomizeRetry = true
} = {}) {
this.baseURL = baseURL
this.userAgent = userAgent
this.timeout = timeout
this.limiter = new RateLimit({ limit, period, concurrency })
this.retryOptions = {
retries,
minTimeout: retryDelayMin,
maxTimeout: retryDelayMax,
randomize: randomizeRetry
}
}
/**
* Determine if we should retry the request based on the given error.
* Retry any 5XX response from MusicBrainz, as well as any error in
* `RETRY_CODES`.
*/
shouldRetry (err) {
if (err instanceof MusicBrainzError) {
return err.statusCode >= 500 && err.statusCode < 600
}
return RETRY_CODES[err.code] || false
}
/**
* Send a request without any retrying or rate limiting.
* Use `get` instead.
*/
_get (path, params, info = {}) {
return new Promise((resolve, reject) => {
const options = {
baseUrl: this.baseURL,
url: path,
qs: { ...params, fmt: 'json' },
json: true,
gzip: true,
headers: { 'User-Agent': this.userAgent },
timeout: this.timeout
}
debug(path, info.currentAttempt > 1 ? `(attempt #${info.currentAttempt})` : '')
request(options, (err, response, body) => {
if (err) {
reject(err)
} else if (response.statusCode !== 200) {
const message = (body && body.error) || ''
reject(new MusicBrainzError(message, response.statusCode))
} else {
resolve(body)
}
})
})
}
/**
* Send a request with retrying and rate limiting.
*/
get (path, params) {
return new Promise((resolve, reject) => {
const fn = this._get.bind(this)
const operation = retry.operation(this.retryOptions)
operation.attempt(currentAttempt => {
// This will increase the priority in our `RateLimit` queue for each
// retry, so that newer requests don't delay this one further.
const priority = currentAttempt
this.limiter.enqueue(fn, [path, params, { currentAttempt }], priority)
.then(resolve)
.catch(err => {
if (!this.shouldRetry(err) || !operation.retry(err)) {
reject(operation.mainError() || err)
}
})
})
})
}
stringifyParams (params) {
if (typeof params.inc === 'object') {
params = {
...params,
inc: params.inc.join('+')
}
}
if (typeof params.type === 'object') {
params = {
...params,
type: params.type.join('|')
}
}
if (typeof params.status === 'object') {
params = {
...params,
status: params.status.join('|')
}
}
return qs.stringify(params, {
skipNulls: true,
filter: (key, value) => value === '' ? undefined : value
})
}
getURL (path, params) {
const query = params ? this.stringifyParams(params) : ''
return query ? `${path}?${query}` : path
}
getLookupURL (entity, id, params) {
return this.getURL(`${entity}/${id}`, params)
}
lookup (entity, id, params = {}) {
const url = this.getLookupURL(entity, id, params)
return this.get(url)
}
getBrowseURL (entity, params) {
return this.getURL(entity, params)
}
browse (entity, params = {}) {
const url = this.getBrowseURL(entity, params)
return this.get(url)
}
getSearchURL (entity, query, params) {
return this.getURL(entity, { ...params, query })
}
search (entity, query, params = {}) {
const url = this.getSearchURL(entity, query, params)
return this.get(url)
}
}
if (require.main === module) {
const client = new MusicBrainz()
const fn = (id) => {
return client.lookup('artist', id).then(artist => {
console.log(chalk.green(`Done: ${id}${artist.name}`))
}).catch(err => {
console.log(chalk.red(`Error: ${id}${err}`))
})
}
fn('f1106b17-dcbb-45f6-b938-199ccfab50cc')
fn('a74b1b7f-71a5-4011-9441-d0b5e4122711')
fn('9b5ae4cc-15ae-4f0b-8a4e-8c44e42ba52a')
fn('26f77379-968b-4435-b486-fc9acb4590d3')
fn('8538e728-ca0b-4321-b7e5-cff6565dd4c0')
}

88
src/api/client.js Normal file
View file

@ -0,0 +1,88 @@
import { fileURLToPath } from 'url';
import createDebug from 'debug';
import got from 'got';
import { readPackageUpSync } from 'read-pkg-up';
import RateLimit from '../rate-limit.js';
import { filterObjectValues, getTypeName } from '../util.js';
const debug = createDebug('graphbrainz:api/client');
const { packageJson: pkg } = readPackageUpSync({
cwd: fileURLToPath(import.meta.url),
});
export default class Client {
constructor({
baseURL,
userAgent = `${pkg.name}/${pkg.version} ` +
`( ${pkg.homepage || pkg.author.url || pkg.author.email} )`,
extraHeaders = {},
timeout = 60000,
limit = 1,
period = 1000,
concurrency = 10,
retry,
} = {}) {
this.baseURL = baseURL;
this.userAgent = userAgent;
this.extraHeaders = extraHeaders;
this.timeout = timeout;
this.limiter = new RateLimit({ limit, period, concurrency });
this.retryOptions = retry;
}
parseErrorMessage(err) {
return err;
}
/**
* Send a request without any rate limiting.
* Use `get` instead.
*/
async _get(path, { searchParams, ...options } = {}) {
const url = new URL(path, this.baseURL);
if (searchParams) {
if (getTypeName(searchParams) === 'Object') {
searchParams = filterObjectValues(
searchParams,
(value) => value != null
);
}
const moreSearchParams = new URLSearchParams(searchParams);
moreSearchParams.forEach((value, key) => {
url.searchParams.set(key, value);
});
}
options = {
responseType: 'json',
timeout: this.timeout,
retry: this.retryOptions,
...options,
headers: {
'User-Agent': this.userAgent,
...this.extraHeaders,
...options.headers,
},
};
let response;
try {
debug(`Sending request. url=%s`, url);
response = await got(url.toString(), options);
debug(`Success: %s url=%s`, response.statusCode, url);
return response;
} catch (err) {
const parsedError = this.parseErrorMessage(err) || err;
debug(`Error: “%s” url=%s`, parsedError, url);
throw parsedError;
}
}
/**
* Send a request with rate limiting.
*/
get(path, options = {}) {
const fn = this._get.bind(this);
return this.limiter.enqueue(fn, [path, options]);
}
}

3
src/api/index.js Normal file
View file

@ -0,0 +1,3 @@
import MusicBrainz from './musicbrainz.js';
export { MusicBrainz as default, MusicBrainz };

109
src/api/musicbrainz.js Normal file
View file

@ -0,0 +1,109 @@
import ExtendableError from 'es6-error';
import Client from './client.js';
import { filterObjectValues } from '../util.js';
export class MusicBrainzError extends ExtendableError {
constructor(message, response) {
super(message);
this.response = response;
}
}
export default class MusicBrainz extends Client {
constructor({
baseURL = process.env.MUSICBRAINZ_BASE_URL ||
'http://musicbrainz.org/ws/2/',
// MusicBrainz API requests are limited to an *average* of 1 req/sec.
// That means if, for example, we only need to make a few API requests to
// fulfill a query, we might as well make them all at once - as long as
// we then wait a few seconds before making more. In practice this can
// seemingly be set to about 5 requests every 5 seconds before we're
// considered to exceed the rate limit.
limit = 5,
period = 5500,
...options
} = {}) {
super({ baseURL, limit, period, ...options });
}
parseErrorMessage(err) {
if (err.name === 'HTTPError') {
const { body } = err.response;
if (body && body.error) {
return new MusicBrainzError(`${body.error}`, err.response);
}
}
return super.parseErrorMessage(err);
}
get(url, options = {}) {
options = {
resolveBodyOnly: true,
...options,
searchParams: {
fmt: 'json',
...options.searchParams,
},
};
return super.get(url, options);
}
stringifyParams(params) {
if (Array.isArray(params.inc)) {
params = {
...params,
inc: params.inc.join('+'),
};
}
if (Array.isArray(params.type)) {
params = {
...params,
type: params.type.join('|'),
};
}
if (Array.isArray(params.status)) {
params = {
...params,
status: params.status.join('|'),
};
}
return new URLSearchParams(
filterObjectValues(params, (value) => value != null && value !== '')
).toString();
}
getURL(path, params) {
const query = params ? this.stringifyParams(params) : '';
return query ? `${path}?${query}` : path;
}
getLookupURL(entity, id, params) {
if (id == null) {
return this.getBrowseURL(entity, params);
}
return this.getURL(`${entity}/${id}`, params);
}
lookup(entity, id, params = {}) {
const url = this.getLookupURL(entity, id, params);
return this.get(url);
}
getBrowseURL(entity, params) {
return this.getURL(entity, params);
}
browse(entity, params = {}) {
const url = this.getBrowseURL(entity, params);
return this.get(url);
}
getSearchURL(entity, query, params) {
return this.getURL(entity, { ...params, query });
}
search(entity, query, params = {}) {
const url = this.getSearchURL(entity, query, params);
return this.get(url);
}
}

30
src/context.js Normal file
View file

@ -0,0 +1,30 @@
import createLoaders from './loaders.js';
import createDebug from 'debug';
const debug = createDebug('graphbrainz:context');
export function extendContext(extension, context, options) {
if (extension.extendContext) {
if (typeof extension.extendContext === 'function') {
debug(
`Extending context via a function from the “${extension.name}” extension.`
);
context = extension.extendContext(context, options);
} else {
throw new Error(
`Extension “${extension.name}” contains an invalid \`extendContext\` ` +
`value: ${extension.extendContext}`
);
}
}
return context;
}
export function createContext(options = {}) {
const { client, extensions = [] } = options;
const loaders = createLoaders(client);
let context = { client, loaders };
return extensions.reduce((context, extension) => {
return extendContext(extension, context, options);
}, context);
}

View file

@ -0,0 +1,55 @@
import ExtendableError from 'es6-error';
import Client from '../../api/client.js';
export class CoverArtArchiveError extends ExtendableError {
constructor(message, response) {
super(message);
this.response = response;
}
}
export default class CoverArtArchiveClient extends Client {
constructor({
baseURL = process.env.COVER_ART_ARCHIVE_BASE_URL ||
'http://coverartarchive.org/',
limit = 10,
period = 1000,
...options
} = {}) {
super({ baseURL, limit, period, ...options });
}
/**
* Sinfully attempt to parse HTML responses for the error message.
*/
parseErrorMessage(err) {
if (err.name === 'HTTPError') {
const { body } = err.response;
if (typeof body === 'string' && body.startsWith('<!')) {
const heading = /<h1>([^<]+)<\/h1>/i.exec(body);
const message = /<p>([^<]+)<\/p>/i.exec(body);
return new CoverArtArchiveError(
`${heading ? heading[1] + ': ' : ''}${message ? message[1] : ''}`,
err.response
);
}
}
return super.parseErrorMessage(err);
}
images(entityType, mbid) {
return this.get(`${entityType}/${mbid}`, { resolveBodyOnly: true });
}
async imageURL(entityType, mbid, typeOrID = 'front', size) {
let url = `${entityType}/${mbid}/${typeOrID}`;
if (size != null) {
url += `-${size}`;
}
const response = await this.get(url, {
method: 'HEAD',
followRedirect: false,
});
return response.headers.location;
}
}

View file

@ -0,0 +1,40 @@
import schema from './schema.js';
import resolvers from './resolvers.js';
import createLoaders from './loaders.js';
import CoverArtArchiveClient from './client.js';
import { ONE_DAY } from '../../util.js';
export default {
name: 'Cover Art Archive',
description: `Retrieve cover art images for releases from the [Cover Art
Archive](https://coverartarchive.org/).`,
extendContext(context, { coverArtClient, coverArtArchive = {} } = {}) {
const client = coverArtClient || new CoverArtArchiveClient(coverArtArchive);
const cacheSize = parseInt(
process.env.COVER_ART_ARCHIVE_CACHE_SIZE ||
process.env.GRAPHBRAINZ_CACHE_SIZE ||
8192,
10
);
const cacheTTL = parseInt(
process.env.COVER_ART_ARCHIVE_CACHE_TTL ||
process.env.GRAPHBRAINZ_CACHE_TTL ||
ONE_DAY,
10
);
return {
...context,
// Add the client instance directly onto `context` for backwards
// compatibility.
coverArtClient: client,
loaders: {
...context.loaders,
...createLoaders({ client, cacheSize, cacheTTL }),
},
};
},
extendSchema: {
schemas: [schema],
resolvers,
},
};

View file

@ -0,0 +1,68 @@
import createDebug from 'debug';
import DataLoader from 'dataloader';
import LRUCache from 'lru-cache';
const debug = createDebug('graphbrainz:extensions/cover-art-archive');
export default function createLoaders(options) {
const { client } = options;
const cache = new LRUCache({
max: options.cacheSize,
maxAge: options.cacheTTL,
dispose(key) {
debug(`Removed from cache. key=${key}`);
},
});
// Make the cache Map-like.
cache.delete = cache.del;
cache.clear = cache.reset;
return {
coverArtArchive: new DataLoader(
(keys) => {
return Promise.all(
keys.map((key) => {
const [entityType, id] = key;
return client
.images(entityType, id)
.catch((err) => {
if (err.response.statusCode === 404) {
return { images: [] };
}
throw err;
})
.then((coverArt) => ({
...coverArt,
_entityType: entityType,
_id: id,
_releaseID:
coverArt.release && coverArt.release.split('/').pop(),
}));
})
);
},
{
cacheKeyFn: ([entityType, id]) => `${entityType}/${id}`,
cacheMap: cache,
}
),
coverArtArchiveURL: new DataLoader(
(keys) => {
return Promise.all(
keys.map((key) => {
const [entityType, id, type, size] = key;
return client.imageURL(entityType, id, type, size);
})
);
},
{
batch: false,
cacheKeyFn: ([entityType, id, type, size]) => {
const key = `${entityType}/${id}/${type}`;
return size ? `${key}-${size}` : key;
},
cacheMap: cache,
}
),
};
}

View file

@ -0,0 +1,75 @@
import { resolveLookup } from '../../resolvers.js';
const SIZES = new Map([
[null, null],
[250, 250],
[500, 500],
['FULL', null],
['SMALL', 250],
['LARGE', 500],
]);
function resolveImage(coverArt, args, { loaders }, info) {
// Since migrating the schema to an extension, we lost custom enum values
// for the time being. Translate any incoming `size` arg to the old enum
// values.
const size = SIZES.get(args.size);
// Field should be `front` or `back`.
const field = info.fieldName;
if (coverArt.images) {
const matches = coverArt.images.filter((image) => image[field]);
if (!matches.length) {
return null;
} else if (matches.length === 1) {
const match = matches[0];
if (size === 250) {
return match.thumbnails.small;
} else if (size === 500) {
return match.thumbnails.large;
} else {
return match.image;
}
}
}
const entityType = coverArt._entityType;
const id = coverArt._id;
const releaseID = coverArt._releaseID;
if (entityType === 'release-group' && field === 'front') {
// Release groups only have an endpoint to retrieve the front image.
// If someone requests the back of a release group, return the back of the
// release that the release group's cover art response points to.
return loaders.coverArtArchiveURL.load(['release-group', id, field, size]);
} else {
return loaders.coverArtArchiveURL.load(['release', releaseID, field, size]);
}
}
export default {
CoverArtArchiveImage: {
fileID: (image) => image.id,
},
CoverArtArchiveRelease: {
front: resolveImage,
back: resolveImage,
images: (coverArt) => coverArt.images,
artwork: (coverArt) => coverArt.images.length > 0,
count: (coverArt) => coverArt.images.length,
release: (coverArt, args, context, info) => {
const mbid = coverArt._releaseID;
if (mbid) {
return resolveLookup(coverArt, { mbid }, context, info);
}
return null;
},
},
Release: {
coverArtArchive: (release, args, { loaders }) => {
return loaders.coverArtArchive.load(['release', release.id]);
},
},
ReleaseGroup: {
coverArtArchive: (releaseGroup, args, { loaders }) => {
return loaders.coverArtArchive.load(['release-group', releaseGroup.id]);
},
},
};

View file

@ -0,0 +1,176 @@
import gql from '../../tag.js';
export default gql`
"""
An individual piece of album artwork from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).
"""
type CoverArtArchiveImage {
"""
The Internet Archives internal file ID for the image.
"""
fileID: String!
"""
The URL at which the image can be found.
"""
image: URLString!
"""
A set of thumbnails for the image.
"""
thumbnails: CoverArtArchiveImageThumbnails!
"""
Whether this image depicts the main front of the release.
"""
front: Boolean!
"""
Whether this image depicts the main back of the release.
"""
back: Boolean!
"""
A list of [image types](https://musicbrainz.org/doc/Cover_Art/Types)
describing what part(s) of the release the image includes.
"""
types: [String]!
"""
The MusicBrainz edit ID.
"""
edit: Int
"""
Whether the image was approved by the MusicBrainz edit system.
"""
approved: Boolean
"""
A free-text comment left for the image.
"""
comment: String
}
"""
The image sizes that may be requested at the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).
"""
enum CoverArtArchiveImageSize {
"""
A maximum dimension of 250px.
"""
SMALL
"""
A maximum dimension of 500px.
"""
LARGE
"""
The images original dimensions, with no maximum.
"""
FULL
}
"""
URLs for thumbnails of different sizes for a particular piece of cover art.
"""
type CoverArtArchiveImageThumbnails {
"""
The URL of a small version of the cover art, where the maximum dimension is
250px.
"""
small: URLString
"""
The URL of a large version of the cover art, where the maximum dimension is
500px.
"""
large: URLString
}
"""
An object containing a list of the cover art images for a release obtained
from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive),
as well as a summary of what artwork is available.
"""
type CoverArtArchiveRelease {
"""
The URL of an image depicting the album cover or main front of the release,
i.e. the front of the packaging of the audio recording (or in the case of a
digital release, the image associated with it in a digital media store).
In the MusicBrainz schema, this field is a Boolean value indicating the
presence of a front image, whereas here the value is the URL for the image
itself if one exists. You can check for null if you just want to determine
the presence of an image.
"""
front(
"""
The size of the image to retrieve. By default, the returned image will
have its full original dimensions, but certain thumbnail sizes may be
retrieved as well.
"""
size: CoverArtArchiveImageSize = FULL
): URLString
"""
The URL of an image depicting the main back of the release, i.e. the back
of the packaging of the audio recording.
In the MusicBrainz schema, this field is a Boolean value indicating the
presence of a back image, whereas here the value is the URL for the image
itself. You can check for null if you just want to determine the presence of
an image.
"""
back(
"""
The size of the image to retrieve. By default, the returned image will
have its full original dimensions, but certain thumbnail sizes may be
retrieved as well.
"""
size: CoverArtArchiveImageSize = FULL
): URLString
"""
A list of images depicting the different sides and surfaces of a releases
media and packaging.
"""
images: [CoverArtArchiveImage]!
"""
Whether there is artwork present for this release.
"""
artwork: Boolean!
"""
The number of artwork images present for this release.
"""
count: Int!
"""
The particular release shown in the returned cover art.
"""
release: Release
}
extend type Release {
"""
An object containing a list and summary of the cover art images that are
present for this release from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).
This field is provided by the Cover Art Archive extension.
"""
coverArtArchive: CoverArtArchiveRelease
}
extend type ReleaseGroup {
"""
The cover art for a release in the release group, obtained from the
[Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive). A
release in the release group will be chosen as representative of the release
group.
This field is provided by the Cover Art Archive extension.
"""
coverArtArchive: CoverArtArchiveRelease
}
`;

View file

@ -0,0 +1,61 @@
import ExtendableError from 'es6-error';
import Client from '../../api/client.js';
export class FanArtError extends ExtendableError {}
export default class FanArtClient extends Client {
constructor({
apiKey = process.env.FANART_API_KEY,
baseURL = process.env.FANART_BASE_URL || 'http://webservice.fanart.tv/v3/',
limit = 10,
period = 1000,
...options
} = {}) {
super({ baseURL, limit, period, ...options });
this.apiKey = apiKey;
}
get(path, options = {}) {
if (!this.apiKey) {
return Promise.reject(
new FanArtError('No API key was configured for the fanart.tv client.')
);
}
options = {
resolveBodyOnly: true,
...options,
searchParams: {
...options.searchParams,
api_key: this.apiKey,
},
};
return super.get(path, options);
}
musicEntity(entityType, mbid) {
switch (entityType) {
case 'artist':
return this.musicArtist(mbid);
case 'label':
return this.musicLabel(mbid);
case 'release-group':
return this.musicAlbum(mbid);
default:
return Promise.reject(
new FanArtError(`Entity type unsupported: ${entityType}`)
);
}
}
musicArtist(mbid) {
return this.get(`music/${mbid}`);
}
musicAlbum(mbid) {
return this.get(`music/albums/${mbid}`);
}
musicLabel(mbid) {
return this.get(`music/${mbid}`);
}
}

View file

@ -0,0 +1,37 @@
import schema from './schema.js';
import resolvers from './resolvers.js';
import createLoader from './loader.js';
import FanArtClient from './client.js';
import { ONE_DAY } from '../../util.js';
export default {
name: 'fanart.tv',
description: `Retrieve high quality artwork for artists, releases, and labels
from [fanart.tv](https://fanart.tv/).`,
extendContext(context, { fanArt = {} } = {}) {
const client = new FanArtClient(fanArt);
const cacheSize = parseInt(
process.env.FANART_CACHE_SIZE ||
process.env.GRAPHBRAINZ_CACHE_SIZE ||
8192,
10
);
const cacheTTL = parseInt(
process.env.FANART_CACHE_TTL ||
process.env.GRAPHBRAINZ_CACHE_TTL ||
ONE_DAY,
10
);
return {
...context,
loaders: {
...context.loaders,
fanArt: createLoader({ client, cacheSize, cacheTTL }),
},
};
},
extendSchema: {
schemas: [schema],
resolvers,
},
};

View file

@ -0,0 +1,65 @@
import createDebug from 'debug';
import DataLoader from 'dataloader';
import LRUCache from 'lru-cache';
const debug = createDebug('graphbrainz:extensions/fanart-tv');
export default function createLoader(options) {
const { client } = options;
const cache = new LRUCache({
max: options.cacheSize,
maxAge: options.cacheTTL,
dispose(key) {
debug(`Removed from cache. key=${key}`);
},
});
// Make the cache Map-like.
cache.delete = cache.del;
cache.clear = cache.reset;
const loader = new DataLoader(
(keys) => {
return Promise.all(
keys.map((key) => {
const [entityType, id] = key;
return client
.musicEntity(entityType, id)
.catch((err) => {
if (err.statusCode === 404) {
// 404s are OK, just return empty data.
return {
artistbackground: [],
artistthumb: [],
musiclogo: [],
hdmusiclogo: [],
musicbanner: [],
musiclabel: [],
albums: {},
};
}
throw err;
})
.then((body) => {
if (entityType === 'artist') {
const releaseGroupIDs = Object.keys(body.albums || {});
debug(
`Priming album cache with ${releaseGroupIDs.length} album(s).`
);
releaseGroupIDs.forEach((key) =>
loader.prime(['release-group', key], body)
);
}
return body;
});
})
);
},
{
batch: false,
cacheKeyFn: ([entityType, id]) => `${entityType}/${id}`,
cacheMap: cache,
}
);
return loader;
}

View file

@ -0,0 +1,64 @@
const imageResolvers = {
imageID: (image) => image.id,
url: (image, args) => {
return args.size === 'PREVIEW'
? image.url.replace('/fanart/', '/preview/')
: image.url;
},
likeCount: (image) => image.likes,
};
export default {
FanArtImage: {
...imageResolvers,
},
FanArtDiscImage: {
...imageResolvers,
discNumber: (image) => image.disc,
},
FanArtLabelImage: {
...imageResolvers,
color: (image) => image.colour,
},
FanArtArtist: {
backgrounds: (artist) => {
return artist.artistbackground || [];
},
thumbnails: (artist) => {
return artist.artistthumb || [];
},
logos: (artist) => {
return artist.musiclogo || [];
},
logosHD: (artist) => {
return artist.hdmusiclogo || [];
},
banners: (artist) => {
return artist.musicbanner || [];
},
},
FanArtLabel: {
logos: (label) => label.musiclabel || [],
},
FanArtAlbum: {
albumCovers: (album) => album.albumcover || [],
discImages: (album) => album.cdart || [],
},
Artist: {
fanArt: (artist, args, context) => {
return context.loaders.fanArt.load(['artist', artist.id]);
},
},
Label: {
fanArt: (label, args, context) => {
return context.loaders.fanArt.load(['label', label.id]);
},
},
ReleaseGroup: {
fanArt: (releaseGroup, args, context) => {
return context.loaders.fanArt
.load(['release-group', releaseGroup.id])
.then((artist) => artist.albums[releaseGroup.id]);
},
},
};

View file

@ -0,0 +1,197 @@
import gql from '../../tag.js';
export default gql`
"""
The image sizes that may be requested at [fanart.tv](https://fanart.tv/).
"""
enum FanArtImageSize {
"""
The images full original dimensions.
"""
FULL
"""
A maximum dimension of 200px.
"""
PREVIEW
}
"""
A single image from [fanart.tv](https://fanart.tv/).
"""
type FanArtImage {
"""
The ID of the image on fanart.tv.
"""
imageID: ID
"""
The URL of the image.
"""
url(
"""
The size of the image to retrieve.
"""
size: FanArtImageSize = FULL
): URLString
"""
The number of likes the image has received by fanart.tv users.
"""
likeCount: Int
}
"""
A disc image from [fanart.tv](https://fanart.tv/).
"""
type FanArtDiscImage {
"""
The ID of the image on fanart.tv.
"""
imageID: ID
"""
The URL of the image.
"""
url(
"""
The size of the image to retrieve.
"""
size: FanArtImageSize = FULL
): URLString
"""
The number of likes the image has received by fanart.tv users.
"""
likeCount: Int
"""
The disc number.
"""
discNumber: Int
"""
The width and height of the (square) disc image.
"""
size: Int
}
"""
A music label image from [fanart.tv](https://fanart.tv/).
"""
type FanArtLabelImage {
"""
The ID of the image on fanart.tv.
"""
imageID: ID
"""
The URL of the image.
"""
url(
"""
The size of the image to retrieve.
"""
size: FanArtImageSize = FULL
): URLString
"""
The number of likes the image has received by fanart.tv users.
"""
likeCount: Int
"""
The type of color content in the image (usually white or colour).
"""
color: String
}
"""
An object containing lists of the different types of artist images from
[fanart.tv](https://fanart.tv/).
"""
type FanArtArtist {
"""
A list of 1920x1080 JPG images picturing the artist, suitable for use as
backgrounds.
"""
backgrounds: [FanArtImage]
"""
A list of 1000x185 JPG images containing the artist and their logo or name.
"""
banners: [FanArtImage]
"""
A list of 400x155 PNG images containing the artists logo or name, with
transparent backgrounds.
"""
logos: [FanArtImage]
"""
A list of 800x310 PNG images containing the artists logo or name, with
transparent backgrounds.
"""
logosHD: [FanArtImage]
"""
A list of 1000x1000 JPG thumbnail images picturing the artist (usually
containing every member of a band).
"""
thumbnails: [FanArtImage]
}
"""
An object containing lists of the different types of label images from
[fanart.tv](https://fanart.tv/).
"""
type FanArtLabel {
"""
A list of 400x270 PNG images containing the labels logo. There will
usually be a black version, a color version, and a white version, all with
transparent backgrounds.
"""
logos: [FanArtLabelImage]
}
"""
An object containing lists of the different types of release group images from
[fanart.tv](https://fanart.tv/).
"""
type FanArtAlbum {
"""
A list of 1000x1000 JPG images of the cover artwork of the release group.
"""
albumCovers: [FanArtImage]
"""
A list of 1000x1000 PNG images of the physical disc media for the release
group, with transparent backgrounds.
"""
discImages: [FanArtDiscImage]
}
extend type Artist {
"""
Images of the artist from [fanart.tv](https://fanart.tv/).
This field is provided by the fanart.tv extension.
"""
fanArt: FanArtArtist
}
extend type Label {
"""
Images of the label from [fanart.tv](https://fanart.tv/).
This field is provided by the fanart.tv extension.
"""
fanArt: FanArtLabel
}
extend type ReleaseGroup {
"""
Images of the release group from [fanart.tv](https://fanart.tv/).
This field is provided by the fanart.tv extension.
"""
fanArt: FanArtAlbum
}
`;

18
src/extensions/index.js Normal file
View file

@ -0,0 +1,18 @@
export async function loadExtension(extensionModule) {
let extension;
if (typeof extensionModule === 'string') {
extension = await import(extensionModule);
} else {
extension = extensionModule;
}
if (extension == null || typeof extension !== 'object') {
throw new Error(
`Expected ${extensionModule} to export an extension but instead ` +
`got: ${extension}`
);
} else if (extension.default) {
// ECMAScript module interop.
extension = extension.default;
}
return extension;
}

View file

@ -0,0 +1,54 @@
import ExtendableError from 'es6-error';
import Client from '../../api/client.js';
export class MediaWikiError extends ExtendableError {}
export default class MediaWikiClient extends Client {
constructor({ limit = 10, period = 1000, ...options } = {}) {
super({ limit, period, ...options });
}
imageInfo(page) {
const pageURL = new URL(page);
if (!pageURL.pathname.startsWith('/wiki/')) {
return Promise.reject(
new MediaWikiError(
`MediaWiki page URL does not have the expected /wiki/ prefix: ${page}`
)
);
}
const apiURL = new URL('/w/api.php', pageURL);
apiURL.search = new URLSearchParams({
action: 'query',
titles: decodeURI(pageURL.pathname.slice(6)),
prop: 'imageinfo',
iiprop: 'url|size|canonicaltitle|user|extmetadata',
format: 'json',
}).toString();
return this.get(apiURL.toString(), { resolveBodyOnly: true }).then(
(body) => {
const pageIDs = Object.keys(body.query.pages);
if (pageIDs.length !== 1) {
throw new MediaWikiError(
`Query returned multiple pages: [${pageIDs.join(', ')}]`
);
}
if (pageIDs[0] === '-1') {
throw new MediaWikiError(
body.query.pages['-1'].invalidreason || 'Unknown error'
);
}
const imageInfo = body.query.pages[pageIDs[0]].imageinfo;
if (imageInfo.length !== 1) {
throw new MediaWikiError(
`Query returned info for ${imageInfo.length} images, expected 1.`
);
}
return imageInfo[0];
}
);
}
}

View file

@ -0,0 +1,37 @@
import schema from './schema.js';
import resolvers from './resolvers.js';
import createLoader from './loader.js';
import MediaWikiClient from './client.js';
import { ONE_DAY } from '../../util.js';
export default {
name: 'MediaWiki',
description: `Retrieve information from MediaWiki image pages, like the actual
image file URL and EXIF metadata.`,
extendContext(context, { mediaWiki = {} } = {}) {
const client = new MediaWikiClient(mediaWiki);
const cacheSize = parseInt(
process.env.MEDIAWIKI_CACHE_SIZE ||
process.env.GRAPHBRAINZ_CACHE_SIZE ||
8192,
10
);
const cacheTTL = parseInt(
process.env.MEDIAWIKI_CACHE_TTL ||
process.env.GRAPHBRAINZ_CACHE_TTL ||
ONE_DAY,
10
);
return {
...context,
loaders: {
...context.loaders,
mediaWiki: createLoader({ client, cacheSize, cacheTTL }),
},
};
},
extendSchema: {
schemas: [schema],
resolvers,
},
};

View file

@ -0,0 +1,30 @@
import createDebug from 'debug';
import DataLoader from 'dataloader';
import LRUCache from 'lru-cache';
const debug = createDebug('graphbrainz:extensions/mediawiki');
export default function createLoader(options) {
const { client } = options;
const cache = new LRUCache({
max: options.cacheSize,
maxAge: options.cacheTTL,
dispose(key) {
debug(`Removed from cache. key=${key}`);
},
});
// Make the cache Map-like.
cache.delete = cache.del;
cache.clear = cache.reset;
return new DataLoader(
(keys) => {
return Promise.allSettled(
keys.map((key) => client.imageInfo(key))
).then((results) =>
results.map((result) => result.reason || result.value)
);
},
{ batch: false, cacheMap: cache }
);
}

View file

@ -0,0 +1,80 @@
async function resolveMediaWikiImages(source, args, { loaders }) {
const isURL = (relation) => relation['target-type'] === 'url';
let rels = source.relations ? source.relations.filter(isURL) : [];
if (!rels.length) {
rels = await loaders.lookup
.load([source._type, source.id, { inc: 'url-rels' }])
.then((source) => source.relations.filter(isURL));
}
const pages = rels
.filter((rel) => {
if (rel.type === args.type) {
const url = new URL(rel.url.resource);
if (url.pathname.match(/^\/wiki\/(File|Image):/)) {
return true;
}
}
return false;
})
.map((rel) => rel.url.resource);
return Promise.all(pages.map((page) => loaders.mediaWiki.load(page)));
}
export default {
MediaWikiImage: {
descriptionURL: (imageInfo) => imageInfo.descriptionurl,
canonicalTitle: (imageInfo) => imageInfo.canonicaltitle,
objectName: (imageInfo) => {
const data = imageInfo.extmetadata.ObjectName;
return data ? data.value : null;
},
descriptionHTML: (imageInfo) => {
const data = imageInfo.extmetadata.ImageDescription;
return data ? data.value : null;
},
originalDateTimeHTML: (imageInfo) => {
const data = imageInfo.extmetadata.DateTimeOriginal;
return data ? data.value : null;
},
categories: (imageInfo) => {
const data = imageInfo.extmetadata.Categories;
return data ? data.value.split('|') : [];
},
artistHTML: (imageInfo) => {
const data = imageInfo.extmetadata.Artist;
return data ? data.value : null;
},
creditHTML: (imageInfo) => {
const data = imageInfo.extmetadata.Credit;
return data ? data.value : null;
},
licenseShortName: (imageInfo) => {
const data = imageInfo.extmetadata.LicenseShortName;
return data ? data.value : null;
},
licenseURL: (imageInfo) => {
const data = imageInfo.extmetadata.LicenseUrl;
return data ? data.value : null;
},
metadata: (imageInfo) =>
Object.keys(imageInfo.extmetadata).map((key) => {
const data = imageInfo.extmetadata[key];
return { ...data, name: key };
}),
},
MediaWikiImageMetadata: {
value: (obj) => (obj.value == null ? obj.value : `${obj.value}`),
},
Artist: {
mediaWikiImages: resolveMediaWikiImages,
},
Instrument: {
mediaWikiImages: resolveMediaWikiImages,
},
Label: {
mediaWikiImages: resolveMediaWikiImages,
},
Place: {
mediaWikiImages: resolveMediaWikiImages,
},
};

View file

@ -0,0 +1,168 @@
import gql from '../../tag.js';
export default gql`
"""
An object describing various properties of an image stored on a MediaWiki
server. The information comes the [MediaWiki imageinfo API](https://www.mediawiki.org/wiki/API:Imageinfo).
"""
type MediaWikiImage {
"""
The URL of the actual image file.
"""
url: URLString!
"""
The URL of the wiki page describing the image.
"""
descriptionURL: URLString
"""
The user who uploaded the file.
"""
user: String
"""
The size of the file in bytes.
"""
size: Int
"""
The pixel width of the image.
"""
width: Int
"""
The pixel height of the image.
"""
height: Int
"""
The canonical title of the file.
"""
canonicalTitle: String
"""
The image title, brief description, or file name.
"""
objectName: String
"""
A description of the image, potentially containing HTML.
"""
descriptionHTML: String
"""
The original date of creation of the image. May be a description rather than
a parseable timestamp, and may contain HTML.
"""
originalDateTimeHTML: String
"""
A list of the categories of the image.
"""
categories: [String]!
"""
The name of the image author, potentially containing HTML.
"""
artistHTML: String
"""
The source of the image, potentially containing HTML.
"""
creditHTML: String
"""
A short human-readable license name.
"""
licenseShortName: String
"""
A web address where the license is described.
"""
licenseURL: URLString
"""
The full list of values in the \`extmetadata\` field.
"""
metadata: [MediaWikiImageMetadata]!
}
"""
An entry in the \`extmetadata\` field of a MediaWiki image file.
"""
type MediaWikiImageMetadata {
"""
The name of the metadata field.
"""
name: String!
"""
The value of the metadata field. All values will be converted to strings.
"""
value: String
"""
The source of the value.
"""
source: String
}
extend type Artist {
"""
Artist images found at MediaWiki URLs in the artists URL relationships.
Defaults to URL relationships with the type image.
This field is provided by the MediaWiki extension.
"""
mediaWikiImages(
"""
The type of URL relationship that will be selected to find images. See
the possible [Artist-URL relationship types](https://musicbrainz.org/relationships/artist-url).
"""
type: String = "image"
): [MediaWikiImage]!
}
extend type Instrument {
"""
Instrument images found at MediaWiki URLs in the instruments URL
relationships. Defaults to URL relationships with the type image.
This field is provided by the MediaWiki extension.
"""
mediaWikiImages(
"""
The type of URL relationship that will be selected to find images. See the
possible [Instrument-URL relationship types](https://musicbrainz.org/relationships/instrument-url).
"""
type: String = "image"
): [MediaWikiImage]!
}
extend type Label {
"""
Label images found at MediaWiki URLs in the labels URL relationships.
Defaults to URL relationships with the type logo.
This field is provided by the MediaWiki extension.
"""
mediaWikiImages(
"""
The type of URL relationship that will be selected to find images. See the
possible [Label-URL relationship types](https://musicbrainz.org/relationships/label-url).
"""
type: String = "logo"
): [MediaWikiImage]!
}
extend type Place {
"""
Place images found at MediaWiki URLs in the places URL relationships.
Defaults to URL relationships with the type image.
This field is provided by the MediaWiki extension.
"""
mediaWikiImages(
"""
The type of URL relationship that will be selected to find images. See the
possible [Place-URL relationship types](https://musicbrainz.org/relationships/place-url).
"""
type: String = "image"
): [MediaWikiImage]!
}
`;

View file

@ -0,0 +1,78 @@
import ExtendableError from 'es6-error';
import Client from '../../api/client.js';
export class TheAudioDBError extends ExtendableError {}
export default class TheAudioDBClient extends Client {
constructor({
apiKey = process.env.THEAUDIODB_API_KEY,
baseURL = process.env.THEAUDIODB_BASE_URL ||
'https://www.theaudiodb.com/api/v1/json/',
limit = 10,
period = 1000,
...options
} = {}) {
super({ baseURL, limit, period, ...options });
this.apiKey = apiKey;
}
get(path, options = {}) {
if (!this.apiKey) {
return Promise.reject(
new TheAudioDBError('No API key was configured for TheAudioDB client.')
);
}
return super.get(`${this.apiKey}/${path}`, {
resolveBodyOnly: true,
...options,
});
}
entity(entityType, mbid) {
switch (entityType) {
case 'artist':
return this.artist(mbid);
case 'release-group':
return this.album(mbid);
case 'recording':
return this.track(mbid);
default:
return Promise.reject(
new TheAudioDBError(`Entity type unsupported: ${entityType}`)
);
}
}
artist(mbid) {
return this.get('artist-mb.php', { searchParams: { i: mbid } }).then(
(body) => {
if (body.artists && body.artists.length === 1) {
return body.artists[0];
}
return null;
}
);
}
album(mbid) {
return this.get('album-mb.php', { searchParams: { i: mbid } }).then(
(body) => {
if (body.album && body.album.length === 1) {
return body.album[0];
}
return null;
}
);
}
track(mbid) {
return this.get('track-mb.php', { searchParams: { i: mbid } }).then(
(body) => {
if (body.track && body.track.length === 1) {
return body.track[0];
}
return null;
}
);
}
}

View file

@ -0,0 +1,37 @@
import schema from './schema.js';
import resolvers from './resolvers.js';
import createLoader from './loader.js';
import TheAudioDBClient from './client.js';
import { ONE_DAY } from '../../util.js';
export default {
name: 'TheAudioDB',
description: `Retrieve images and information about artists, releases, and
recordings from [TheAudioDB.com](http://www.theaudiodb.com/).`,
extendContext(context, { theAudioDB = {} } = {}) {
const client = new TheAudioDBClient(theAudioDB);
const cacheSize = parseInt(
process.env.THEAUDIODB_CACHE_SIZE ||
process.env.GRAPHBRAINZ_CACHE_SIZE ||
8192,
10
);
const cacheTTL = parseInt(
process.env.THEAUDIODB_CACHE_TTL ||
process.env.GRAPHBRAINZ_CACHE_TTL ||
ONE_DAY,
10
);
return {
...context,
loaders: {
...context.loaders,
theAudioDB: createLoader({ client, cacheSize, cacheTTL }),
},
};
},
extendSchema: {
schemas: [schema],
resolvers,
},
};

View file

@ -0,0 +1,35 @@
import createDebug from 'debug';
import DataLoader from 'dataloader';
import LRUCache from 'lru-cache';
const debug = createDebug('graphbrainz:extensions/the-audio-db');
export default function createLoader(options) {
const { client } = options;
const cache = new LRUCache({
max: options.cacheSize,
maxAge: options.cacheTTL,
dispose(key) {
debug(`Removed from cache. key=${key}`);
},
});
// Make the cache Map-like.
cache.delete = cache.del;
cache.clear = cache.reset;
return new DataLoader(
(keys) => {
return Promise.all(
keys.map((key) => {
const [entityType, id] = key;
return client.entity(entityType, id);
})
);
},
{
batch: false,
cacheKeyFn: ([entityType, id]) => `${entityType}/${id}`,
cacheMap: cache,
}
);
}

View file

@ -0,0 +1,118 @@
function handleImageSize(resolver) {
return (source, args, context, info) => {
const getURL = (url) => (args.size === 'PREVIEW' ? `${url}/preview` : url);
const url = resolver(source, args, context, info);
if (!url) {
return null;
} else if (Array.isArray(url)) {
return url.map(getURL);
} else {
return getURL(url);
}
};
}
export default {
TheAudioDBArtist: {
artistID: (artist) => artist.idArtist,
biography: (artist, args) => {
const lang = args.lang.toUpperCase();
return artist[`strBiography${lang}`] || null;
},
memberCount: (artist) => artist.intMembers,
banner: handleImageSize((artist) => artist.strArtistBanner),
fanArt: handleImageSize((artist) => {
return [
artist.strArtistFanart,
artist.strArtistFanart2,
artist.strArtistFanart3,
].filter(Boolean);
}),
logo: handleImageSize((artist) => artist.strArtistLogo),
thumbnail: handleImageSize((artist) => artist.strArtistThumb),
genre: (artist) => artist.strGenre || null,
mood: (artist) => artist.strMood || null,
style: (artist) => artist.strStyle || null,
},
TheAudioDBAlbum: {
albumID: (album) => album.idAlbum,
artistID: (album) => album.idArtist,
description: (album, args) => {
const lang = args.lang.toUpperCase();
return album[`strDescription${lang}`] || null;
},
salesCount: (album) => album.intSales,
score: (album) => album.intScore,
scoreVotes: (album) => album.intScoreVotes,
discImage: handleImageSize((album) => album.strAlbumCDart),
spineImage: handleImageSize((album) => album.strAlbumSpine),
frontImage: handleImageSize((album) => album.strAlbumThumb),
backImage: handleImageSize((album) => album.strAlbumThumbBack),
review: (album) => album.strReview || null,
genre: (album) => album.strGenre || null,
mood: (album) => album.strMood || null,
style: (album) => album.strStyle || null,
speed: (album) => album.strSpeed || null,
theme: (album) => album.strTheme || null,
},
TheAudioDBTrack: {
trackID: (track) => track.idTrack,
albumID: (track) => track.idAlbum,
artistID: (track) => track.idArtist,
description: (track, args) => {
const lang = args.lang.toUpperCase();
return track[`strDescription${lang}`] || null;
},
thumbnail: handleImageSize((track) => track.strTrackThumb),
score: (track) => track.intScore,
scoreVotes: (track) => track.intScoreVotes,
trackNumber: (track) => track.intTrackNumber,
musicVideo: (track) => track,
genre: (track) => track.strGenre || null,
mood: (track) => track.strMood || null,
style: (track) => track.strStyle || null,
theme: (track) => track.strTheme || null,
},
TheAudioDBMusicVideo: {
url: (track) => {
let url = track.strMusicVid || null;
// Many of these are missing the protocol and start with www, so add it
// in that case.
if (url && url.startsWith('www.')) {
url = `https://${url}`;
}
return url;
},
companyName: (track) => track.strMusicVidCompany || null,
directorName: (track) => track.strMusicVidDirector || null,
screenshots: handleImageSize((track) => {
return [
track.strMusicVidScreen1,
track.strMusicVidScreen2,
track.strMusicVidScreen3,
].filter(Boolean);
}),
viewCount: (track) => track.intMusicVidViews,
likeCount: (track) => track.intMusicVidLikes,
dislikeCount: (track) => track.intMusicVidDislikes,
commentCount: (track) => track.intMusicVidComments,
},
Artist: {
theAudioDB: (artist, args, context) => {
return context.loaders.theAudioDB.load(['artist', artist.id]);
},
},
Recording: {
theAudioDB: (recording, args, context) => {
return context.loaders.theAudioDB.load(['recording', recording.id]);
},
},
ReleaseGroup: {
theAudioDB: (releaseGroup, args, context) => {
return context.loaders.theAudioDB.load([
'release-group',
releaseGroup.id,
]);
},
},
};

View file

@ -0,0 +1,372 @@
import gql from '../../tag.js';
export default gql`
"""
The image sizes that may be requested at [TheAudioDB](http://www.theaudiodb.com/).
"""
enum TheAudioDBImageSize {
"""
The images full original dimensions.
"""
FULL
"""
A maximum dimension of 200px.
"""
PREVIEW
}
"""
An artist on [TheAudioDB](http://www.theaudiodb.com/).
"""
type TheAudioDBArtist {
"""
TheAudioDB ID of the artist.
"""
artistID: ID
"""
A biography of the artist, often available in several languages.
"""
biography(
"""
The two-letter code for the language in which to retrieve the biography.
"""
lang: String = "en"
): String
"""
The number of members in the musical group, if applicable.
"""
memberCount: Int
"""
A 1000x185 JPG banner image containing the artist and their logo or name.
"""
banner(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
A list of 1280x720 or 1920x1080 JPG images depicting the artist.
"""
fanArt(
"""
The size of the images to retrieve.
"""
size: TheAudioDBImageSize = FULL
): [URLString]!
"""
A 400x155 PNG image containing the artists logo or name, with a transparent
background.
"""
logo(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
A 1000x1000 JPG thumbnail image picturing the artist (usually containing
every member of a band).
"""
thumbnail(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
The primary musical genre of the artist (e.g. Alternative Rock).
"""
genre: String
"""
The primary musical mood of the artist (e.g. Sad).
"""
mood: String
"""
The primary musical style of the artist (e.g. Rock/Pop).
"""
style: String
}
"""
An album on [TheAudioDB](http://www.theaudiodb.com/) corresponding with a
MusicBrainz Release Group.
"""
type TheAudioDBAlbum {
"""
TheAudioDB ID of the album.
"""
albumID: ID
"""
TheAudioDB ID of the artist who released the album.
"""
artistID: ID
"""
A description of the album, often available in several languages.
"""
description(
"""
The two-letter code for the language in which to retrieve the biography.
"""
lang: String = "en"
): String
"""
A review of the album.
"""
review: String
"""
The worldwide sales figure.
"""
salesCount: Float
"""
The albums rating as determined by user votes, out of 10.
"""
score: Float
"""
The number of users who voted to determine the albums score.
"""
scoreVotes: Float
"""
An image of the physical disc media for the album.
"""
discImage(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
An image of the spine of the album packaging.
"""
spineImage(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
An image of the front of the album packaging.
"""
frontImage(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
An image of the back of the album packaging.
"""
backImage(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
The primary musical genre of the album (e.g. Alternative Rock).
"""
genre: String
"""
The primary musical mood of the album (e.g. Sad).
"""
mood: String
"""
The primary musical style of the album (e.g. Rock/Pop).
"""
style: String
"""
A rough description of the primary musical speed of the album (e.g. Medium).
"""
speed: String
"""
The primary musical theme of the album (e.g. In Love).
"""
theme: String
}
"""
A track on [TheAudioDB](http://www.theaudiodb.com/) corresponding with a
MusicBrainz Recording.
"""
type TheAudioDBTrack {
"""
TheAudioDB ID of the track.
"""
trackID: ID
"""
TheAudioDB ID of the album on which the track appears.
"""
albumID: ID
"""
TheAudioDB ID of the artist who released the track.
"""
artistID: ID
"""
A description of the track.
"""
description(
"""
The two-letter code for the language in which to retrieve the description.
"""
lang: String = "en"
): String
"""
A thumbnail image for the track.
"""
thumbnail(
"""
The size of the image to retrieve.
"""
size: TheAudioDBImageSize = FULL
): URLString
"""
The tracks rating as determined by user votes, out of 10.
"""
score: Float
"""
The number of users who voted to determine the albums score.
"""
scoreVotes: Float
"""
The track number of the song on the album.
"""
trackNumber: Int
"""
The official music video for the track.
"""
musicVideo: TheAudioDBMusicVideo
"""
The primary musical genre of the track (e.g. Alternative Rock).
"""
genre: String
"""
The primary musical mood of the track (e.g. Sad).
"""
mood: String
"""
The primary musical style of the track (e.g. Rock/Pop).
"""
style: String
"""
The primary musical theme of the track (e.g. In Love).
"""
theme: String
}
"""
Details of a music video associated with a track on [TheAudioDB](http://www.theaudiodb.com/).
"""
type TheAudioDBMusicVideo {
"""
The URL where the music video can be found.
"""
url: URLString
"""
The video production company of the music video.
"""
companyName: String
"""
The director of the music video.
"""
directorName: String
"""
A list of still images from the music video.
"""
screenshots(
"""
The size of the images to retrieve.
"""
size: TheAudioDBImageSize = FULL
): [URLString]!
"""
The number of views the video has received at the given URL. This will rarely
be up to date, so use cautiously.
"""
viewCount: Float
"""
The number of likes the video has received at the given URL. This will rarely
be up to date, so use cautiously.
"""
likeCount: Float
"""
The number of dislikes the video has received at the given URL. This will
rarely be up to date, so use cautiously.
"""
dislikeCount: Float
"""
The number of comments the video has received at the given URL. This will
rarely be up to date, so use cautiously.
"""
commentCount: Float
}
extend type Artist {
"""
Data about the artist from [TheAudioDB](http://www.theaudiodb.com/), a good
source of biographical information and images.
This field is provided by TheAudioDB extension.
"""
theAudioDB: TheAudioDBArtist
}
extend type Recording {
"""
Data about the recording from [TheAudioDB](http://www.theaudiodb.com/).
This field is provided by TheAudioDB extension.
"""
theAudioDB: TheAudioDBTrack
}
extend type ReleaseGroup {
"""
Data about the release group from [TheAudioDB](http://www.theaudiodb.com/),
a good source of descriptive information, reviews, and images.
This field is provided by TheAudioDB extension.
"""
theAudioDB: TheAudioDBAlbum
}
`;

View file

@ -1,39 +1,104 @@
import express from 'express'
import graphqlHTTP from 'express-graphql'
import compression from 'compression'
import MusicBrainz from './api'
import schema from './schema'
import createLoaders from './loaders'
import express from 'express';
import ExpressGraphQL from 'express-graphql';
import compression from 'compression';
import cors from 'cors';
import MusicBrainz from './api/index.js';
import Client from './api/client.js';
import { baseSchema, createSchema } from './schema.js';
import { createContext } from './context.js';
import { loadExtension } from './extensions/index.js';
import gql from './tag.js';
const { graphqlHTTP } = ExpressGraphQL;
const formatError = (err) => ({
message: err.message,
locations: err.locations,
stack: err.stack
})
stack: err.stack,
});
const middleware = ({ client = new MusicBrainz(), ...options } = {}) => {
const DEV = process.env.NODE_ENV !== 'production'
const graphiql = DEV || process.env.GRAPHBRAINZ_GRAPHIQL === 'true'
const loaders = createLoaders(client)
return graphqlHTTP({
schema,
context: { client, loaders },
pretty: DEV,
graphiql,
formatError: DEV ? formatError : undefined,
...options
})
const defaultExtensions = [
'graphbrainz/extensions/cover-art-archive',
'graphbrainz/extensions/fanart-tv',
'graphbrainz/extensions/mediawiki',
'graphbrainz/extensions/the-audio-db',
];
function middleware({
client = new MusicBrainz(),
extensions = process.env.GRAPHBRAINZ_EXTENSIONS
? JSON.parse(process.env.GRAPHBRAINZ_EXTENSIONS)
: defaultExtensions,
...middlewareOptions
} = {}) {
const DEV = process.env.NODE_ENV !== 'production';
const graphiql = DEV || process.env.GRAPHBRAINZ_GRAPHIQL === 'true';
const getAsyncMiddleware = async () => {
const loadedExtensions = await Promise.all(
extensions.map((extensionSpecifier) => loadExtension(extensionSpecifier))
);
const options = {
client,
extensions: loadedExtensions,
...middlewareOptions,
};
const schema = createSchema(baseSchema, options);
const context = createContext(options);
return graphqlHTTP({
schema,
context,
pretty: DEV,
graphiql,
customFormatErrorFn: DEV ? formatError : undefined,
...middlewareOptions,
});
};
const asyncMiddleware = getAsyncMiddleware();
return async (req, res, next) => {
try {
const middleware = await asyncMiddleware;
middleware(req, res, next);
} catch (err) {
next(err);
}
};
}
export default middleware
if (require.main === module) {
require('dotenv').config({ silent: true })
const app = express()
const port = process.env.PORT || 3000
const route = process.env.GRAPHBRAINZ_PATH || '/'
app.use(compression())
app.use(route, middleware())
app.listen(port)
console.log(`Listening on port ${port}.`)
async function start(options) {
const dotenv = await import('dotenv');
dotenv.config({ silent: true });
const app = express();
const port = process.env.PORT || 3000;
const route = process.env.GRAPHBRAINZ_PATH || '/';
const corsOptions = {
origin: process.env.GRAPHBRAINZ_CORS_ORIGIN || false,
methods: 'HEAD,GET,POST',
};
switch (corsOptions.origin) {
case 'true':
corsOptions.origin = true;
break;
case 'false':
corsOptions.origin = false;
break;
default:
break;
}
app.use(compression());
app.use(route, cors(corsOptions), middleware(options));
app.listen(port);
console.log(`Listening on port ${port}.`);
}
export {
Client,
MusicBrainz,
gql,
baseSchema,
createContext,
createSchema,
defaultExtensions,
loadExtension,
middleware,
start,
};

View file

@ -1,77 +1,92 @@
import DataLoader from 'dataloader'
import LRUCache from 'lru-cache'
import { toPlural } from './types/helpers'
import createDebug from 'debug';
import DataLoader from 'dataloader';
import LRUCache from 'lru-cache';
import { ONE_DAY, toPlural } from './util.js';
const debug = require('debug')('graphbrainz:loaders')
const ONE_DAY = 24 * 60 * 60 * 1000
const debug = createDebug('graphbrainz:loaders');
export default function createLoaders (client) {
export default function createLoaders(client) {
// All loaders share a single LRU cache that will remember 8192 responses,
// each cached for 1 day.
const cache = LRUCache({
const cache = new LRUCache({
max: parseInt(process.env.GRAPHBRAINZ_CACHE_SIZE || 8192, 10),
maxAge: parseInt(process.env.GRAPHBRAINZ_CACHE_TTL || ONE_DAY, 10),
dispose (key) {
debug(`Removed '${key}' from cache.`)
}
})
dispose(key) {
debug(`Removed from cache. key=${key}`);
},
});
// Make the cache Map-like.
cache.delete = cache.del
cache.clear = cache.reset
cache.delete = cache.del;
cache.clear = cache.reset;
const lookup = new DataLoader(keys => {
return Promise.all(keys.map(key => {
const [ entityType, id, params ] = key
return client.lookup(entityType, id, params).then(entity => {
if (entity) {
// Store the entity type so we can determine what type of object this
// is elsewhere in the code.
entity._type = entityType
entity._inc = params.inc
}
return entity
})
}))
}, {
cacheKeyFn: (key) => client.getLookupURL(...key),
cacheMap: cache
})
const browse = new DataLoader(keys => {
return Promise.all(keys.map(key => {
const [ entityType, params ] = key
return client.browse(entityType, params).then(list => {
list[toPlural(entityType)].forEach(entity => {
// Store the entity type so we can determine what type of object this
// is elsewhere in the code.
entity._type = entityType
entity._inc = params.inc
const lookup = new DataLoader(
(keys) => {
return Promise.all(
keys.map((key) => {
const [entityType, id, params = {}] = key;
return client.lookup(entityType, id, params).then((entity) => {
if (entity) {
// Store the entity type so we can determine what type of object this
// is elsewhere in the code.
entity._type = entityType;
}
return entity;
});
})
return list
})
}))
}, {
cacheKeyFn: (key) => client.getBrowseURL(...key),
cacheMap: cache
})
);
},
{
batch: false,
cacheKeyFn: (key) => client.getLookupURL(...key),
cacheMap: cache,
}
);
const search = new DataLoader(keys => {
return Promise.all(keys.map(key => {
const [ entityType, query, params ] = key
return client.search(entityType, query, params).then(list => {
list[toPlural(entityType)].forEach(entity => {
// Store the entity type so we can determine what type of object this
// is elsewhere in the code.
entity._type = entityType
entity._inc = params.inc
const browse = new DataLoader(
(keys) => {
return Promise.all(
keys.map((key) => {
const [entityType, params = {}] = key;
return client.browse(entityType, params).then((list) => {
list[toPlural(entityType)].forEach((entity) => {
// Store the entity type so we can determine what type of object this
// is elsewhere in the code.
entity._type = entityType;
});
return list;
});
})
return list
})
}))
}, {
cacheKeyFn: (key) => client.getSearchURL(...key),
cacheMap: cache
})
);
},
{
batch: false,
cacheKeyFn: (key) => client.getBrowseURL(...key),
cacheMap: cache,
}
);
return { lookup, browse, search }
const search = new DataLoader(
(keys) => {
return Promise.all(
keys.map((key) => {
const [entityType, query, params = {}] = key;
return client.search(entityType, query, params).then((list) => {
list[toPlural(entityType)].forEach((entity) => {
// Store the entity type so we can determine what type of object this
// is elsewhere in the code.
entity._type = entityType;
});
return list;
});
})
);
},
{
batch: false,
cacheKeyFn: (key) => client.getSearchURL(...key),
cacheMap: cache,
}
);
return { lookup, browse, search };
}

View file

@ -1,58 +1,80 @@
import { GraphQLObjectType } from 'graphql'
import { forwardConnectionArgs } from 'graphql-relay'
import { browseResolver } from '../resolvers'
import GraphQL from 'graphql';
import GraphQLRelay from 'graphql-relay';
import { resolveBrowse } from '../resolvers.js';
import {
MBID,
URLString,
AreaConnection,
ArtistConnection,
CollectionConnection,
EventConnection,
DiscID,
ISRC,
ISWC,
LabelConnection,
PlaceConnection,
RecordingConnection,
ReleaseConnection,
ReleaseGroupConnection,
URLConnection,
WorkConnection
} from '../types'
import { toWords } from '../types/helpers'
WorkConnection,
} from '../types/index.js';
import { releaseGroupType, releaseStatus } from '../types/helpers.js';
import { toWords } from '../util.js';
const { GraphQLObjectType, GraphQLString } = GraphQL;
const { forwardConnectionArgs } = GraphQLRelay;
const area = {
type: MBID,
description: 'The MBID of an area to which the entity is linked.'
}
description: 'The MBID of an area to which the entity is linked.',
};
const artist = {
type: MBID,
description: 'The MBID of an artist to which the entity is linked.'
}
description: 'The MBID of an artist to which the entity is linked.',
};
const collection = {
type: MBID,
description: 'The MBID of a collection in which the entity is found.'
}
description: 'The MBID of a collection in which the entity is found.',
};
const event = {
type: MBID,
description: 'The MBID of an event to which the entity is linked.',
};
const label = {
type: MBID,
description: 'The MBID of a label to which the entity is linked.',
};
const place = {
type: MBID,
description: 'The MBID of a place to which the entity is linked.',
};
const recording = {
type: MBID,
description: 'The MBID of a recording to which the entity is linked.'
}
description: 'The MBID of a recording to which the entity is linked.',
};
const release = {
type: MBID,
description: 'The MBID of a release to which the entity is linked.'
}
description: 'The MBID of a release to which the entity is linked.',
};
const releaseGroup = {
type: MBID,
description: 'The MBID of a release group to which the entity is linked.'
}
description: 'The MBID of a release group to which the entity is linked.',
};
const work = {
type: MBID,
description: 'The MBID of a work to which the entity is linked.',
};
function browseQuery (connectionType, args) {
const typeName = toWords(connectionType.name.slice(0, -10))
function createBrowseField(connectionType, args) {
const typeName = toWords(connectionType.name.slice(0, -10));
return {
type: connectionType,
description: `Browse ${typeName} entities linked to the given arguments.`,
args: {
...args,
...forwardConnectionArgs,
...args
},
resolve: browseResolver()
}
resolve: resolveBrowse,
};
}
export const BrowseQuery = new GraphQLObjectType({
@ -60,87 +82,106 @@ export const BrowseQuery = new GraphQLObjectType({
description: `A query for all MusicBrainz entities directly linked to another
entity.`,
fields: {
areas: browseQuery(AreaConnection, {
collection
areas: createBrowseField(AreaConnection, {
collection,
}),
artists: browseQuery(ArtistConnection, {
artists: createBrowseField(ArtistConnection, {
area,
collection,
recording,
release,
releaseGroup,
work: {
type: MBID,
description: 'The MBID of a work to which the artist is linked.'
}
work,
}),
events: browseQuery(EventConnection, {
collections: createBrowseField(CollectionConnection, {
area,
artist,
collection,
place: {
type: MBID,
description: 'The MBID of a place to which the event is linked.'
}
}),
labels: browseQuery(LabelConnection, {
area,
collection,
release
}),
places: browseQuery(PlaceConnection, {
area,
collection
}),
recordings: browseQuery(RecordingConnection, {
artist,
collection,
release
}),
releases: browseQuery(ReleaseConnection, {
area,
artist,
collection,
label: {
type: MBID,
description: 'The MBID of a label to which the release is linked.'
editor: {
type: GraphQLString,
description: 'The username of the editor who created the collection.',
},
event,
label,
place,
recording,
release,
releaseGroup,
work,
}),
events: createBrowseField(EventConnection, {
area,
artist,
collection,
place,
}),
labels: createBrowseField(LabelConnection, {
area,
collection,
release,
}),
places: createBrowseField(PlaceConnection, {
area,
collection,
}),
recordings: createBrowseField(RecordingConnection, {
artist,
collection,
isrc: {
type: ISRC,
description: `The [International Standard Recording Code](https://musicbrainz.org/doc/ISRC)
(ISRC) of the recording.`,
},
release,
}),
releases: createBrowseField(ReleaseConnection, {
area,
artist,
collection,
discID: {
type: DiscID,
description: `A [disc ID](https://musicbrainz.org/doc/Disc_ID)
associated with the release.`,
},
label,
recording,
releaseGroup,
track: {
type: MBID,
description: 'The MBID of a track that is included in the release.'
description: 'The MBID of a track that is included in the release.',
},
trackArtist: {
type: MBID,
description: `The MBID of an artist that appears on a track in the
release, but is not included in the credits for the release itself.`
release, but is not included in the credits for the release itself.`,
},
recording,
releaseGroup
type: releaseGroupType,
status: releaseStatus,
}),
releaseGroups: browseQuery(ReleaseGroupConnection, {
releaseGroups: createBrowseField(ReleaseGroupConnection, {
artist,
collection,
release
release,
type: releaseGroupType,
}),
works: browseQuery(WorkConnection, {
works: createBrowseField(WorkConnection, {
artist,
collection
collection,
iswc: {
type: ISWC,
description: `The [International Standard Musical Work Code](https://musicbrainz.org/doc/ISWC)
(ISWC) of the work.`,
},
}),
urls: browseQuery(URLConnection, {
resource: {
type: URLString,
description: 'The web address for which to browse URL entities.'
}
})
}
})
},
});
export const browseField = {
export const browse = {
type: BrowseQuery,
description: 'Browse all MusicBrainz entities directly linked to another entity.',
description:
'Browse all MusicBrainz entities directly linked to another entity.',
// We only have work to do once we know what entity types are being requested,
// so this can just resolve to an empty object.
resolve: () => ({})
}
resolve: () => ({}),
};
export default BrowseQuery
export default BrowseQuery;

View file

@ -1,3 +1,3 @@
export { LookupQuery, lookupField } from './lookup'
export { BrowseQuery, browseField } from './browse'
export { SearchQuery, searchField } from './search'
export { LookupQuery, lookup } from './lookup.js';
export { BrowseQuery, browse } from './browse.js';
export { SearchQuery, search } from './search.js';

View file

@ -1,56 +1,90 @@
import { GraphQLObjectType } from 'graphql'
import { lookupResolver } from '../resolvers'
import { mbid, toWords } from '../types/helpers'
import GraphQL from 'graphql';
import { resolveLookup } from '../resolvers.js';
import { mbid } from '../types/helpers.js';
import {
Area,
Artist,
Collection,
Disc,
DiscID,
Event,
Instrument,
Label,
MBID,
Place,
Recording,
Release,
ReleaseGroup,
Series,
URL,
Work
} from '../types'
URLString,
Work,
} from '../types/index.js';
import { toWords } from '../util.js';
function lookupQuery (entity) {
const typeName = toWords(entity.name)
const { GraphQLObjectType, GraphQLNonNull } = GraphQL;
function createLookupField(entity, args) {
const typeName = toWords(entity.name);
return {
type: entity,
description: `Look up a specific ${typeName} by its MBID.`,
args: { mbid },
resolve: lookupResolver()
}
args: { mbid, ...args },
resolve: resolveLookup,
};
}
export const LookupQuery = new GraphQLObjectType({
name: 'LookupQuery',
description: 'A lookup of an individual MusicBrainz entity by its MBID.',
fields: {
area: lookupQuery(Area),
artist: lookupQuery(Artist),
event: lookupQuery(Event),
instrument: lookupQuery(Instrument),
label: lookupQuery(Label),
place: lookupQuery(Place),
recording: lookupQuery(Recording),
release: lookupQuery(Release),
releaseGroup: lookupQuery(ReleaseGroup),
series: lookupQuery(Series),
url: lookupQuery(URL),
work: lookupQuery(Work)
}
})
area: createLookupField(Area),
artist: createLookupField(Artist),
collection: createLookupField(Collection),
disc: {
type: Disc,
description: 'Look up a specific physical disc by its disc ID.',
args: {
discID: {
type: new GraphQLNonNull(DiscID),
description: `The [disc ID](https://musicbrainz.org/doc/Disc_ID)
of the disc.`,
},
},
resolve: (root, { discID }, { loaders }, info) => {
return loaders.lookup.load(['discid', discID]);
},
},
event: createLookupField(Event),
instrument: createLookupField(Instrument),
label: createLookupField(Label),
place: createLookupField(Place),
recording: createLookupField(Recording),
release: createLookupField(Release),
releaseGroup: createLookupField(ReleaseGroup),
series: createLookupField(Series),
url: createLookupField(URL, {
mbid: {
...mbid,
// Remove the non-null requirement that is usually on the `mbid` field
// so that URLs can be looked up by `resource`.
type: MBID,
},
resource: {
type: URLString,
description: 'The web address of the URL entity to look up.',
},
}),
work: createLookupField(Work),
},
});
export const lookupField = {
export const lookup = {
type: LookupQuery,
description: 'Perform a lookup of a MusicBrainz entity by its MBID.',
// We only have work to do once we know what entity types are being requested,
// so this can just resolve to an empty object.
resolve: () => ({})
}
resolve: () => ({}),
};
export default LookupQuery
export default LookupQuery;

View file

@ -1,6 +1,6 @@
import { GraphQLObjectType, GraphQLNonNull, GraphQLString } from 'graphql'
import { forwardConnectionArgs } from 'graphql-relay'
import { searchResolver } from '../resolvers'
import GraphQL from 'graphql';
import GraphQLRelay from 'graphql-relay';
import { resolveSearch } from '../resolvers.js';
import {
AreaConnection,
ArtistConnection,
@ -12,12 +12,15 @@ import {
ReleaseConnection,
ReleaseGroupConnection,
SeriesConnection,
WorkConnection
} from '../types'
import { toWords } from '../types/helpers'
WorkConnection,
} from '../types/index.js';
import { toWords } from '../util.js';
function searchQuery (connectionType) {
const typeName = toWords(connectionType.name.slice(0, -10))
const { GraphQLObjectType, GraphQLNonNull, GraphQLString } = GraphQL;
const { forwardConnectionArgs } = GraphQLRelay;
function createSearchField(connectionType) {
const typeName = toWords(connectionType.name.slice(0, -10));
return {
type: connectionType,
description: `Search for ${typeName} entities matching the given query.`,
@ -25,38 +28,38 @@ function searchQuery (connectionType) {
query: {
type: new GraphQLNonNull(GraphQLString),
description: `The query terms, in Lucene search syntax. See [examples
and search fields](https://musicbrainz.org/doc/Development/XML_Web_Service/Version_2/Search).`
and search fields](https://musicbrainz.org/doc/Development/XML_Web_Service/Version_2/Search).`,
},
...forwardConnectionArgs
...forwardConnectionArgs,
},
resolve: searchResolver()
}
resolve: resolveSearch,
};
}
export const SearchQuery = new GraphQLObjectType({
name: 'SearchQuery',
description: 'A search for MusicBrainz entities using Lucene query syntax.',
fields: {
areas: searchQuery(AreaConnection),
artists: searchQuery(ArtistConnection),
events: searchQuery(EventConnection),
instruments: searchQuery(InstrumentConnection),
labels: searchQuery(LabelConnection),
places: searchQuery(PlaceConnection),
recordings: searchQuery(RecordingConnection),
releases: searchQuery(ReleaseConnection),
releaseGroups: searchQuery(ReleaseGroupConnection),
series: searchQuery(SeriesConnection),
works: searchQuery(WorkConnection)
}
})
areas: createSearchField(AreaConnection),
artists: createSearchField(ArtistConnection),
events: createSearchField(EventConnection),
instruments: createSearchField(InstrumentConnection),
labels: createSearchField(LabelConnection),
places: createSearchField(PlaceConnection),
recordings: createSearchField(RecordingConnection),
releases: createSearchField(ReleaseConnection),
releaseGroups: createSearchField(ReleaseGroupConnection),
series: createSearchField(SeriesConnection),
works: createSearchField(WorkConnection),
},
});
export const searchField = {
export const search = {
type: SearchQuery,
description: 'Search for MusicBrainz entities using Lucene query syntax.',
// We only have work to do once we know what entity types are being requested,
// so this can just resolve to an empty object.
resolve: () => ({})
}
resolve: () => ({}),
};
export default SearchQuery
export default SearchQuery;

View file

@ -1,141 +1,104 @@
import createDebug from 'debug';
const debug = createDebug('graphbrainz:rate-limit');
export default class RateLimit {
constructor ({
constructor({
limit = 1,
period = 1000,
concurrency = limit || 1,
defaultPriority = 1
defaultPriority = 1,
} = {}) {
this.limit = limit
this.period = period
this.defaultPriority = defaultPriority
this.concurrency = concurrency
this.queues = []
this.numPending = 0
this.periodStart = null
this.periodCapacity = this.limit
this.timer = null
this.pendingFlush = false
this.paused = false
this.limit = limit;
this.period = period;
this.defaultPriority = defaultPriority;
this.concurrency = concurrency;
this.queues = [];
this.numPending = 0;
this.periodStart = null;
this.periodCapacity = this.limit;
this.timer = null;
this.pendingFlush = false;
this.prevTaskID = null;
}
pause () {
this.paused = true
nextTaskID(prevTaskID = this.prevTaskID) {
const id = (prevTaskID || 0) + 1;
this.prevTaskID = id;
return id;
}
unpause () {
this.paused = false
this.flush()
}
clear () {
this.queues.length = 0
}
enqueue (fn, args, priority = this.defaultPriority) {
priority = Math.max(0, priority)
enqueue(fn, args, priority = this.defaultPriority) {
priority = Math.max(0, priority);
return new Promise((resolve, reject) => {
const queue = this.queues[priority] = this.queues[priority] || []
queue.push({ fn, args, resolve, reject })
const queue = (this.queues[priority] = this.queues[priority] || []);
const id = this.nextTaskID();
debug(`Enqueuing task. id=${id} priority=${priority}`);
queue.push({ fn, args, resolve, reject, id });
if (!this.pendingFlush) {
this.pendingFlush = true
this.pendingFlush = true;
process.nextTick(() => {
this.pendingFlush = false
this.flush()
})
this.pendingFlush = false;
this.flush();
});
}
})
});
}
dequeue () {
let task
dequeue() {
let task;
for (let i = this.queues.length - 1; i >= 0; i--) {
const queue = this.queues[i]
const queue = this.queues[i];
if (queue && queue.length) {
task = queue.shift()
task = queue.shift();
}
if (!queue || !queue.length) {
this.queues.length = i
this.queues.length = i;
}
if (task) {
break
break;
}
}
return task
return task;
}
flush () {
if (this.paused) {
return
}
flush() {
if (this.numPending < this.concurrency && this.periodCapacity > 0) {
const task = this.dequeue()
const task = this.dequeue();
if (task) {
const { resolve, reject, fn, args } = task
const { resolve, reject, fn, args, id } = task;
if (this.timer == null) {
const now = Date.now()
let timeout = this.period
const now = Date.now();
let timeout = this.period;
if (this.periodStart != null) {
const delay = now - (this.periodStart + timeout)
const delay = now - (this.periodStart + timeout);
if (delay > 0 && delay <= timeout) {
timeout -= delay
timeout -= delay;
}
}
this.periodStart = now
this.periodStart = now;
this.timer = setTimeout(() => {
this.timer = null
this.periodCapacity = this.limit
this.flush()
}, timeout)
this.timer = null;
this.periodCapacity = this.limit;
this.flush();
}, timeout);
}
this.numPending += 1
this.periodCapacity -= 1
this.numPending += 1;
this.periodCapacity -= 1;
const onResolve = (value) => {
this.numPending -= 1
resolve(value)
this.flush()
}
this.numPending -= 1;
resolve(value);
this.flush();
};
const onReject = (err) => {
this.numPending -= 1
reject(err)
this.flush()
}
fn(...args).then(onResolve, onReject)
this.flush()
this.numPending -= 1;
reject(err);
this.flush();
};
debug(`Running task. id=${id}`);
fn(...args).then(onResolve, onReject);
this.flush();
}
}
}
}
if (require.main === module) {
const t0 = Date.now()
const logTime = (...args) => {
const t = Date.now()
console.log(`[t=${t - t0}]`, ...args)
}
const limiter = new RateLimit({
limit: 3,
period: 3000,
concurrency: 5
})
const fn = (i) => {
return new Promise((resolve) => {
setTimeout(() => {
logTime(`Finished task ${i}`)
resolve(i)
}, 7000)
})
}
limiter.enqueue(fn, [1])
limiter.enqueue(fn, [2])
limiter.enqueue(fn, [3])
limiter.enqueue(fn, [4], 2)
limiter.enqueue(fn, [5], 10)
limiter.enqueue(fn, [6])
limiter.enqueue(fn, [7])
limiter.enqueue(fn, [8])
limiter.enqueue(fn, [9])
limiter.enqueue(fn, [10])
}

View file

@ -1,173 +1,216 @@
import { toDashed, toSingular } from './types/helpers'
import {
import GraphQLRelay from 'graphql-relay';
import { getFields, extendIncludes, toDashed, toSingular } from './util.js';
const {
getOffsetWithDefault,
connectionFromArray,
connectionFromArraySlice
} from 'graphql-relay'
import { getFields, extendIncludes } from './util'
connectionFromArraySlice,
} = GraphQLRelay;
export function includeRelationships (params, info, fragments = info.fragments) {
let fields = getFields(info, fragments)
export function includeRelationships(params, info, fragments = info.fragments) {
let fields = getFields(info, fragments);
if (info.fieldName !== 'relationships') {
if (fields.relationships) {
fields = getFields(fields.relationships, fragments)
fields = getFields(fields.relationships, fragments);
} else {
if (fields.edges) {
fields = getFields(fields.edges, fragments)
fields = getFields(fields.edges, fragments);
if (fields.node) {
return includeRelationships(params, fields.node, fragments)
return includeRelationships(params, fields.node, fragments);
}
}
return params
return params;
}
}
if (fields) {
const relationships = Object.keys(fields)
const includeRels = relationships.map(field => {
return `${toDashed(toSingular(field))}-rels`
})
const relationships = Object.keys(fields);
const includeRels = relationships.map((field) => {
return `${toDashed(toSingular(field))}-rels`;
});
if (includeRels.length) {
params = {
...params,
inc: extendIncludes(params.inc, includeRels)
}
inc: extendIncludes(params.inc, includeRels),
};
}
}
return params
return params;
}
export function includeSubqueries (params, info, fragments = info.fragments) {
export function includeSubqueries(params, info, fragments = info.fragments) {
const subqueryIncludes = {
aliases: 'aliases',
artistCredit: 'artist-credits',
tags: 'tags'
}
let fields = getFields(info, fragments)
const include = []
aliases: ['aliases'],
artistCredit: ['artist-credits'],
artistCredits: ['artist-credits'],
isrcs: ['isrcs'],
media: ['media'],
'media.discs': ['discids'],
'media.tracks': ['recordings'],
rating: ['ratings'],
tags: ['tags'],
};
let fields = getFields(info, fragments, 1);
const include = [];
for (const key in subqueryIncludes) {
if (fields[key]) {
const value = subqueryIncludes[key]
include.push(value)
const field = fields[key];
if (field) {
const value = subqueryIncludes[key];
include.push(...value);
}
}
params = {
...params,
inc: extendIncludes(params.inc, include)
inc: extendIncludes(params.inc, include),
};
if (fields['edges.node']) {
params = includeSubqueries(params, fields['edges.node'], fragments);
}
if (fields.edges) {
fields = getFields(fields.edges, fragments)
if (fields.node) {
params = includeSubqueries(params, fields.node, fragments)
return params;
}
export function resolveLookup(root, { mbid, ...params }, { loaders }, info) {
if (!mbid && !params.resource) {
throw new Error(
'Lookups by a field other than MBID must provide: resource'
);
}
const entityType = toDashed(info.fieldName);
params = includeSubqueries(params, info);
params = includeRelationships(params, info);
return loaders.lookup.load([entityType, mbid, params]);
}
export function resolveBrowse(
root,
{ first, after, type = [], status = [], discID, isrc, iswc, ...args },
{ loaders },
info
) {
const pluralName = toDashed(info.fieldName);
const singularName = toSingular(pluralName);
let params = {
...args,
type,
status,
limit: first,
offset: getOffsetWithDefault(after, -1) + 1 || undefined,
};
params = includeSubqueries(params, info);
params = includeRelationships(params, info, info.fragments);
const formatParam = (value) => value.toLowerCase().replace(/ /g, '');
params.type = params.type.map(formatParam);
params.status = params.status.map(formatParam);
let request;
if (discID) {
request = loaders.lookup.load(['discid', discID, params]);
// If fetching releases by disc ID, they will already include the `media`
// and `discids` subqueries, and it is invalid to specify them.
if (params.inc) {
params.inc = params.inc.filter((value) => {
return value !== 'media' && value !== 'discids';
});
}
} else if (isrc) {
request = loaders.lookup.load(['isrc', isrc, params]).then((result) => {
result[pluralName].forEach((entity) => {
entity._type = singularName;
});
return result;
});
} else if (iswc) {
request = loaders.lookup.load(['iswc', iswc, params]);
} else {
request = loaders.browse.load([singularName, params]);
}
return params
}
export function lookupResolver () {
return (root, { mbid }, { loaders }, info) => {
const entityType = toDashed(info.fieldName)
let params = includeSubqueries({}, info)
params = includeRelationships(params, info)
return loaders.lookup.load([entityType, mbid, params])
}
}
export function browseResolver () {
return (source, { first = 25, after, type = [], status = [], ...args }, { loaders }, info) => {
const pluralName = toDashed(info.fieldName)
const singularName = toSingular(pluralName)
let params = {
...args,
type,
status,
limit: first,
offset: getOffsetWithDefault(after, -1) + 1
}
params = includeSubqueries(params, info)
params = includeRelationships(params, info, info.fragments)
const formatValue = value => value.toLowerCase().replace(/ /g, '')
params.type = params.type.map(formatValue)
params.status = params.status.map(formatValue)
return loaders.browse.load([singularName, params]).then(list => {
// Grab the list, offet, and count from the response and use them to build
// a Relay connection object.
const {
[pluralName]: arraySlice,
[`${singularName}-offset`]: sliceStart,
[`${singularName}-count`]: arrayLength
} = list
const meta = { sliceStart, arrayLength }
return {
totalCount: arrayLength,
...connectionFromArraySlice(arraySlice, { first, after }, meta)
}
})
}
}
export function searchResolver () {
return (source, { first = 25, after, query, ...args }, { loaders }, info) => {
const pluralName = toDashed(info.fieldName)
const singularName = toSingular(pluralName)
let params = {
...args,
limit: first,
offset: getOffsetWithDefault(after, -1) + 1
}
params = includeSubqueries(params, info)
return loaders.search.load([singularName, query, params]).then(list => {
const {
[pluralName]: arraySlice,
offset: sliceStart,
count: arrayLength
} = list
const meta = { sliceStart, arrayLength }
const connection = {
totalCount: arrayLength,
...connectionFromArraySlice(arraySlice, { first, after }, meta)
}
// Move the `score` field up to the edge object and make sure it's a
// number (MusicBrainz returns a string).
connection.edges.forEach(edge => {
edge.score = parseInt(edge.node.score, 10)
})
return connection
})
}
}
export function relationshipResolver () {
return (source, args, context, info) => {
const targetType = toDashed(toSingular(info.fieldName)).replace('-', '_')
// There's no way to filter these at the API level, so do it here.
const relationships = source.filter(rel => {
if (rel['target-type'] !== targetType) {
return false
}
if (args.direction != null && rel.direction !== args.direction) {
return false
}
if (args.type != null && rel.type !== args.type) {
return false
}
if (args.typeID != null && rel['type-id'] !== args.typeID) {
return false
}
return true
})
return request.then((list) => {
// Grab the list, offet, and count from the response and use them to build
// a Relay connection object.
const {
[pluralName]: arraySlice,
[`${singularName}-offset`]: sliceStart = 0,
[`${singularName}-count`]: arrayLength = arraySlice.length,
} = list;
const meta = { sliceStart, arrayLength };
const connection = connectionFromArraySlice(
arraySlice,
{ first, after },
meta
);
return {
totalCount: relationships.length,
...connectionFromArray(relationships, args)
}
}
nodes: connection.edges.map((edge) => edge.node),
totalCount: arrayLength,
...connection,
};
});
}
export function linkedResolver () {
return (source, args, context, info) => {
const parentEntity = toDashed(info.parentType.name)
args = { ...args, [parentEntity]: source.id }
return browseResolver()(source, args, context, info)
export function resolveSearch(
root,
{ after, first, query, ...args },
{ loaders },
info
) {
const pluralName = toDashed(info.fieldName);
const singularName = toSingular(pluralName);
let params = {
...args,
limit: first,
offset: getOffsetWithDefault(after, -1) + 1 || undefined,
};
params = includeSubqueries(params, info);
return loaders.search.load([singularName, query, params]).then((list) => {
const {
[pluralName]: arraySlice,
offset: sliceStart,
count: arrayLength,
} = list;
const meta = { sliceStart, arrayLength };
const connection = connectionFromArraySlice(
arraySlice,
{ first, after },
meta
);
// Move the `score` field up to the edge object and make sure it's a
// number (MusicBrainz returns a string).
const edges = connection.edges.map((edge) => ({
...edge,
score: +edge.node.score,
}));
const connectionWithExtras = {
nodes: edges.map((edge) => edge.node),
totalCount: arrayLength,
...connection,
edges,
};
return connectionWithExtras;
});
}
export function resolveRelationship(rels, args, context, info) {
const targetType = toDashed(toSingular(info.fieldName)).replace('-', '_');
let matches = rels.filter((rel) => rel['target-type'] === targetType);
// There's no way to filter these at the API level, so do it here.
if (args.direction != null) {
matches = matches.filter((rel) => rel.direction === args.direction);
}
if (args.type != null) {
matches = matches.filter((rel) => rel.type === args.type);
}
if (args.typeID != null) {
matches = matches.filter((rel) => rel['type-id'] === args.typeID);
}
const connection = connectionFromArray(matches, args);
return {
nodes: connection.edges.map((edge) => edge.node),
totalCount: matches.length,
...connection,
};
}
export function resolveLinked(entity, args, context, info) {
const parentEntity = toDashed(info.parentType.name);
args = { ...args, [parentEntity]: entity.id };
return resolveBrowse(entity, args, context, info);
}
/**
@ -175,17 +218,34 @@ export function linkedResolver () {
* for a particular field that's being requested, make another request to grab
* it (after making sure it isn't already available).
*/
export function subqueryResolver (includeValue, handler = value => value) {
return (source, args, { loaders }, info) => {
const key = toDashed(info.fieldName)
if (key in source || (source._inc && source._inc.indexOf(key) !== -1)) {
return handler(source[key], args)
export function createSubqueryResolver(
{ inc, key } = {},
handler = (value) => value
) {
return (entity, args, { loaders }, info) => {
key = key || toDashed(info.fieldName);
let promise;
if (key in entity) {
promise = Promise.resolve(entity);
} else {
const { _type: entityType, id } = source
const params = { inc: [includeValue || key] }
return loaders.lookup.load([entityType, id, params]).then(entity => {
return handler(entity[key], args)
})
const { _type: entityType, id } = entity;
const params = { inc: [inc || key] };
promise = loaders.lookup.load([entityType, id, params]);
}
}
return promise.then((entity) => handler(entity[key], args));
};
}
export function resolveDiscReleases(disc, args, context, info) {
const { releases } = disc;
if (releases != null) {
const connection = connectionFromArray(releases, args);
return {
nodes: connection.edges.map((edge) => edge.node),
totalCount: releases.length,
...connection,
};
}
args = { ...args, discID: disc.id };
return resolveBrowse(disc, args, context, info);
}

View file

@ -1,17 +1,71 @@
import { GraphQLSchema, GraphQLObjectType } from 'graphql'
import { lookupField, browseField, searchField } from './queries'
import { nodeField } from './types/node'
import createDebug from 'debug';
import GraphQL from 'graphql';
import GraphQLToolsSchema from '@graphql-tools/schema';
import { lookup, browse, search } from './queries/index.js';
import { nodeField } from './types/node.js';
export default new GraphQLSchema({
const { GraphQLSchema, GraphQLObjectType, extendSchema, parse } = GraphQL;
const { addResolversToSchema } = GraphQLToolsSchema;
const debug = createDebug('graphbrainz:schema');
export function applyExtension(extension, schema, options = {}) {
let outputSchema = schema;
if (extension.extendSchema) {
if (typeof extension.extendSchema === 'object') {
debug(
`Extending schema via an object from the “${extension.name}” extension.`
);
const { schemas = [], resolvers } = extension.extendSchema;
outputSchema = schemas.reduce((updatedSchema, extensionSchema) => {
if (typeof extensionSchema === 'string') {
extensionSchema = parse(extensionSchema);
}
return extendSchema(updatedSchema, extensionSchema);
}, outputSchema);
if (resolvers) {
outputSchema = addResolversToSchema({
schema: outputSchema,
resolvers,
});
}
} else if (typeof extension.extendSchema === 'function') {
debug(
`Extending schema via a function from the “${extension.name}” extension.`
);
outputSchema = extension.extendSchema(schema, options);
} else {
throw new Error(
`The “${extension.name}” extension contains an invalid ` +
`\`extendSchema\` value: ${extension.extendSchema}`
);
}
}
// Fix for `graphql-tools` creating a new Query type with no description.
if (outputSchema._queryType.description === undefined) {
outputSchema._queryType.description = schema._queryType.description;
}
return outputSchema;
}
export function createSchema(schema, options = {}) {
const { extensions = [] } = options;
return extensions.reduce((updatedSchema, extension) => {
return applyExtension(extension, updatedSchema, options);
}, schema);
}
export const baseSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
description: `The query root, from which multiple types of MusicBrainz
requests can be made.`,
fields: () => ({
lookup,
browse,
search,
node: nodeField,
lookup: lookupField,
browse: browseField,
search: searchField
})
})
})
}),
}),
});

17
src/tag.js Normal file
View file

@ -0,0 +1,17 @@
/**
* This module only exists because as of this writing, `graphql-tag` doesn't
* support type, field, or argument descriptions. That's a bummer, so this
* simple tag is provided instead. It doesn't support any type of interpolation
* whatsoever, but will parse the GraphQL document, allow syntax highlighting,
* and enable Prettier formatting.
*/
import GraphQL from 'graphql';
const { parse } = GraphQL;
export default function gql(literals, ...interpolations) {
if (literals.length !== 1 || interpolations.length) {
throw new Error('The gql template tag does not support interpolation.');
}
return parse(literals[0]);
}

View file

@ -1,11 +1,11 @@
import {
GraphQLObjectType,
GraphQLString,
GraphQLBoolean
} from 'graphql/type'
import { name, sortName, fieldWithID } from './helpers'
import GraphQL from 'graphql';
import { Locale } from './scalars.js';
import { name, sortName, fieldWithID } from './helpers.js';
import { createSubqueryResolver } from '../resolvers.js';
export default new GraphQLObjectType({
const { GraphQLObjectType, GraphQLBoolean, GraphQLList } = GraphQL;
export const Alias = new GraphQLObjectType({
name: 'Alias',
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are variant names
that are mostly used as search help: if a search matches an entitys alias, the
@ -13,22 +13,29 @@ entity will be given as a result even if the actual name wouldnt be.`,
fields: () => ({
name: {
...name,
description: 'The aliased name of the entity.'
description: 'The aliased name of the entity.',
},
sortName,
locale: {
type: GraphQLString,
type: Locale,
description: `The locale (language and/or country) in which the alias is
used.`
used.`,
},
primary: {
type: GraphQLBoolean,
description: `Whether this is the main alias for the entity in the
specified locale (this could mean the most recent or the most common).`
specified locale (this could mean the most recent or the most common).`,
},
...fieldWithID('type', {
description: `The type or purpose of the alias whether it is a variant,
search hint, etc.`
})
})
})
search hint, etc.`,
}),
}),
});
export const aliases = {
type: new GraphQLList(Alias),
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are used to store
alternate names or misspellings.`,
resolve: createSubqueryResolver(),
};

View file

@ -1,24 +1,29 @@
import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql'
import Node from './node'
import Entity from './entity'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import {
id,
mbid,
name,
sortName,
disambiguation,
aliases,
artists,
events,
labels,
places,
releases,
relationships,
tags,
connectionWithExtras
} from './helpers'
fieldWithID,
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { events } from './event.js';
import { aliases } from './alias.js';
import { artists } from './artist.js';
import { labels } from './label.js';
import { places } from './place.js';
import { releases } from './release.js';
import { relationships } from './relationship.js';
import { collections } from './collection.js';
import { tags } from './tag.js';
const Area = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLString, GraphQLList } = GraphQL;
export const Area = new GraphQLObjectType({
name: 'Area',
description: `[Areas](https://musicbrainz.org/doc/Area) are geographic regions
or settlements (countries, cities, or the like).`,
@ -34,17 +39,34 @@ or settlements (countries, cities, or the like).`,
type: new GraphQLList(GraphQLString),
description: `[ISO 3166 codes](https://en.wikipedia.org/wiki/ISO_3166) are
the codes assigned by ISO to countries and subdivisions.`,
resolve: data => data['iso-3166-1-codes']
args: {
standard: {
type: GraphQLString,
description: `Specify the particular ISO standard codes to retrieve.
Available ISO standards are 3166-1, 3166-2, and 3166-3.`,
defaultValue: '3166-1',
},
},
resolve: (data, args) => {
const { standard = '3166-1' } = args;
return data[`iso-${standard}-codes`];
},
},
...fieldWithID('type', {
description: `The type of area (country, city, etc. see the [possible
values](https://musicbrainz.org/doc/Area)).`,
}),
artists,
events,
labels,
places,
releases,
relationships,
tags
})
})
collections,
tags,
}),
});
export const AreaConnection = connectionWithExtras(Area)
export default Area
export const AreaConnection = connectionWithExtras(Area);
export const areas = linkedQuery(AreaConnection);

View file

@ -1,7 +1,10 @@
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
import Artist from './artist'
import GraphQL from 'graphql';
import { Artist } from './artist.js';
import { createSubqueryResolver } from '../resolvers.js';
export default new GraphQLObjectType({
const { GraphQLObjectType, GraphQLString, GraphQLList } = GraphQL;
export const ArtistCredit = new GraphQLObjectType({
name: 'ArtistCredit',
description: `[Artist credits](https://musicbrainz.org/doc/Artist_Credits)
indicate who is the main credited artist (or artists) for releases, release
@ -14,23 +17,41 @@ track, etc., and join phrases between them.`,
description: `The entity representing the artist referenced in the
credits.`,
resolve: (source) => {
const { artist } = source
const { artist } = source;
if (artist) {
artist._type = 'artist'
artist._type = 'artist';
}
return artist
}
return artist;
},
},
name: {
type: GraphQLString,
description: `The name of the artist as credited in the specific release,
track, etc.`
track, etc.`,
},
joinPhrase: {
type: GraphQLString,
description: `Join phrases might include words and/or punctuation to
separate artist names as they appear on the release, track, etc.`,
resolve: data => data['joinphrase']
}
})
})
resolve: (data) => data.joinphrase,
},
}),
});
export const artistCredits = {
type: new GraphQLList(ArtistCredit),
description: 'The main credited artist(s).',
resolve: createSubqueryResolver({
inc: 'artist-credits',
key: 'artist-credit',
}),
};
export const artistCredit = {
...artistCredits,
deprecationReason: `The \`artistCredit\` field has been renamed to
\`artistCredits\`, since it is a list of credits and is referred to in the
plural form throughout the MusicBrainz documentation. This field is deprecated
and will be removed in a major release in the future. Use the equivalent
\`artistCredits\` field.`,
};

View file

@ -1,27 +1,33 @@
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import Area from './area'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { Area } from './area.js';
import { aliases } from './alias.js';
import { collections } from './collection.js';
import { lifeSpan } from './life-span.js';
import { recordings } from './recording.js';
import { releases } from './release.js';
import { releaseGroups } from './release-group.js';
import { works } from './work.js';
import { relationships } from './relationship.js';
import { rating } from './rating.js';
import { tags } from './tag.js';
import { IPI, ISNI } from './scalars.js';
import {
getFallback,
resolveWithFallback,
fieldWithID,
id,
mbid,
name,
sortName,
disambiguation,
aliases,
lifeSpan,
recordings,
releases,
releaseGroups,
works,
relationships,
tags,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
const Artist = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLString, GraphQLList } = GraphQL;
export const Artist = new GraphQLObjectType({
name: 'Artist',
description: `An [artist](https://musicbrainz.org/doc/Artist) is generally a
musician, group of musicians, or other music professional (like a producer or
@ -39,41 +45,54 @@ even a fictional character.`,
country: {
type: GraphQLString,
description: `The country with which an artist is primarily identified. It
is often, but not always, its birth/formation country.`
is often, but not always, its birth/formation country.`,
},
area: {
type: Area,
description: `The area with which an artist is primarily identified. It
is often, but not always, its birth/formation country.`
is often, but not always, its birth/formation country.`,
},
beginArea: {
type: Area,
description: `The area in which an artist began their career (or where
were born, if the artist is a person).`,
resolve: getFallback(['begin-area', 'begin_area'])
they were born, if the artist is a person).`,
resolve: resolveWithFallback(['begin-area', 'begin_area']),
},
endArea: {
type: Area,
description: `The area in which an artist ended their career (or where
they died, if the artist is a person).`,
resolve: getFallback(['end-area', 'end_area'])
resolve: resolveWithFallback(['end-area', 'end_area']),
},
lifeSpan,
...fieldWithID('gender', {
description: `Whether a person or character identifies as male, female, or
neither. Groups do not have genders.`
neither. Groups do not have genders.`,
}),
...fieldWithID('type', {
description: 'Whether an artist is a person, a group, or something else.'
description: 'Whether an artist is a person, a group, or something else.',
}),
ipis: {
type: new GraphQLList(IPI),
description: `List of [Interested Parties Information](https://musicbrainz.org/doc/IPI)
(IPI) codes for the artist.`,
},
isnis: {
type: new GraphQLList(ISNI),
description: `List of [International Standard Name Identifier](https://musicbrainz.org/doc/ISNI)
(ISNI) codes for the artist.`,
},
recordings,
releases,
releaseGroups,
works,
relationships,
tags
})
})
collections,
rating,
tags,
}),
});
export const ArtistConnection = connectionWithExtras(Artist)
export default Artist
export const ArtistConnection = connectionWithExtras(Artist);
export const artists = linkedQuery(ArtistConnection);

67
src/types/collection.js Normal file
View file

@ -0,0 +1,67 @@
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import {
id,
mbid,
name,
fieldWithID,
resolveHyphenated,
createCollectionField,
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { areas } from './area.js';
import { artists } from './artist.js';
import { events } from './event.js';
import { instruments } from './instrument.js';
import { labels } from './label.js';
import { places } from './place.js';
import { recordings } from './recording.js';
import { releases } from './release.js';
import { releaseGroups } from './release-group.js';
import { series } from './series.js';
import { works } from './work.js';
const { GraphQLObjectType, GraphQLNonNull, GraphQLString } = GraphQL;
export const Collection = new GraphQLObjectType({
name: 'Collection',
description: `[Collections](https://musicbrainz.org/doc/Collections) are
lists of entities that users can create.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
mbid,
name,
editor: {
type: new GraphQLNonNull(GraphQLString),
description: 'The username of the editor who created the collection.',
},
entityType: {
type: new GraphQLNonNull(GraphQLString),
description: 'The type of entity listed in the collection.',
resolve: resolveHyphenated,
},
...fieldWithID('type', {
description: 'The type of collection.',
}),
areas: createCollectionField(areas),
artists: createCollectionField(artists),
events: createCollectionField(events),
instruments: createCollectionField(instruments),
labels: createCollectionField(labels),
places: createCollectionField(places),
recordings: createCollectionField(recordings),
releases: createCollectionField(releases),
releaseGroups: createCollectionField(releaseGroups),
series: createCollectionField(series),
works: createCollectionField(works),
}),
});
export const CollectionConnection = connectionWithExtras(Collection);
export const collections = linkedQuery(CollectionConnection, {
description: 'A list of collections containing this entity.',
});

44
src/types/disc.js Normal file
View file

@ -0,0 +1,44 @@
import GraphQL from 'graphql';
import GraphQLRelay from 'graphql-relay';
import { Node } from './node.js';
import { DiscID } from './scalars.js';
import { ReleaseConnection } from './release.js';
import { resolveDiscReleases } from '../resolvers.js';
import { id, resolveHyphenated } from './helpers.js';
const { GraphQLObjectType, GraphQLNonNull, GraphQLList, GraphQLInt } = GraphQL;
const { forwardConnectionArgs } = GraphQLRelay;
export const Disc = new GraphQLObjectType({
name: 'Disc',
description: `Information about the physical CD and releases associated with a
particular [disc ID](https://musicbrainz.org/doc/Disc_ID).`,
interfaces: () => [Node],
fields: () => ({
id,
discID: {
type: new GraphQLNonNull(DiscID),
description: `The [disc ID](https://musicbrainz.org/doc/Disc_ID) of this disc.`,
resolve: (disc) => disc.id,
},
offsetCount: {
type: new GraphQLNonNull(GraphQLInt),
description: 'The number of offsets (tracks) on the disc.',
resolve: resolveHyphenated,
},
offsets: {
type: new GraphQLList(GraphQLInt),
description: 'The sector offset of each track on the disc.',
},
sectors: {
type: new GraphQLNonNull(GraphQLInt),
description: 'The sector offset of the lead-out (the end of the disc).',
},
releases: {
type: ReleaseConnection,
description: 'The list of releases linked to this disc ID.',
args: forwardConnectionArgs,
resolve: resolveDiscReleases,
},
}),
});

View file

@ -1,13 +1,13 @@
import { GraphQLInterfaceType } from 'graphql'
import { mbid } from './helpers'
import GraphQL from 'graphql';
import { mbid, connectionWithExtras, resolveType } from './helpers.js';
export default new GraphQLInterfaceType({
const { GraphQLInterfaceType } = GraphQL;
export const Entity = new GraphQLInterfaceType({
name: 'Entity',
description: 'An entity in the MusicBrainz schema.',
resolveType (value) {
if (value._type && require.resolve(`./${value._type}`)) {
return require(`./${value._type}`).default
}
},
fields: () => ({ mbid })
})
resolveType,
fields: () => ({ mbid }),
});
export const EntityConnection = connectionWithExtras(Entity);

View file

@ -1,4 +1,6 @@
import { GraphQLEnumType } from 'graphql/type'
import GraphQL from 'graphql';
const { GraphQLEnumType } = GraphQL;
export const ArtistType = new GraphQLEnumType({
name: 'ArtistType',
@ -8,36 +10,60 @@ etc.`,
PERSON: {
name: 'Person',
description: 'This indicates an individual person.',
value: 'Person'
value: 'Person',
},
GROUP: {
name: 'Group',
description: `This indicates a group of people that may or may not have a
distinctive name.`,
value: 'Group'
value: 'Group',
},
ORCHESTRA: {
name: 'Orchestra',
description: 'This indicates an orchestra (a large instrumental ensemble).',
value: 'Orchestra'
description:
'This indicates an orchestra (a large instrumental ensemble).',
value: 'Orchestra',
},
CHOIR: {
name: 'Choir',
description: 'This indicates a choir/chorus (a large vocal ensemble).',
value: 'Choir'
value: 'Choir',
},
CHARACTER: {
name: 'Character',
description: 'This indicates an individual fictional character.',
value: 'Character'
value: 'Character',
},
OTHER: {
name: 'Other',
description: 'An artist which does not fit into the other categories.',
value: 'Other'
}
}
})
value: 'Other',
},
},
});
export const CoverArtImageSize = new GraphQLEnumType({
name: 'CoverArtImageSize',
description: `The image sizes that may be requested at the [Cover Art
Archive](https://musicbrainz.org/doc/Cover_Art_Archive).`,
values: {
SMALL: {
name: 'Small',
description: 'A maximum dimension of 250px.',
value: 250,
},
LARGE: {
name: 'Large',
description: 'A maximum dimension of 500px.',
value: 500,
},
FULL: {
name: 'Full',
description: 'The images original dimensions, with no maximum.',
value: null,
},
},
});
export const ReleaseStatus = new GraphQLEnumType({
name: 'ReleaseStatus',
@ -48,29 +74,29 @@ bootleg, etc.`,
name: 'Official',
description: `Any release officially sanctioned by the artist and/or their
record company. (Most releases will fit into this category.)`,
value: 'Official'
value: 'Official',
},
PROMOTION: {
name: 'Promotion',
description: `A giveaway release or a release intended to promote an
upcoming official release, e.g. prerelease albums or releases included with a
magazine.`,
value: 'Promotion'
value: 'Promotion',
},
BOOTLEG: {
name: 'Bootleg',
description: `An unofficial/underground release that was not sanctioned by
the artist and/or the record company.`,
value: 'Bootleg'
value: 'Bootleg',
},
PSEUDORELEASE: {
name: 'Pseudo-Release',
description: `A pseudo-release is a duplicate release for
translation/transliteration purposes.`,
value: 'Pseudo-Release'
}
}
})
value: 'Pseudo-Release',
},
},
});
export const ReleaseGroupType = new GraphQLEnumType({
name: 'ReleaseGroupType',
@ -83,14 +109,14 @@ etc.`,
release, generally consists of previously unreleased material (unless this type
is combined with secondary types which change that, such as Compilation). This
includes album re-issues, with or without bonus tracks.`,
value: 'Album'
value: 'Album',
},
SINGLE: {
name: 'Single',
description: `A single typically has one main song and possibly a handful
of additional tracks or remixes of the main track. A single is usually named
after its main song.`,
value: 'Single'
value: 'Single',
},
EP: {
name: 'EP',
@ -100,57 +126,57 @@ full length release (an LP or “Long Play”) and the tracks are usually exclus
to the EP, in other words the tracks dont come from a previously issued
release. EP is fairly difficult to define; usually it should only be assumed
that a release is an EP if the artist defines it as such.`,
value: 'EP'
value: 'EP',
},
OTHER: {
name: 'Other',
description: 'Any release that does not fit any of the other categories.',
value: 'Other'
value: 'Other',
},
BROADCAST: {
name: 'Broadcast',
description: `An episodic release that was originally broadcast via radio,
television, or the Internet, including podcasts.`,
value: 'Broadcast'
value: 'Broadcast',
},
COMPILATION: {
name: 'Compilation',
description: `A compilation is a collection of previously released tracks
by one or more artists.`,
value: 'Compilation'
value: 'Compilation',
},
SOUNDTRACK: {
name: 'Soundtrack',
description: `A soundtrack is the musical score to a movie, TV series,
stage show, computer game, etc.`,
value: 'Soundtrack'
value: 'Soundtrack',
},
SPOKENWORD: {
name: 'Spoken Word',
description: 'A non-music spoken word release.',
value: 'Spoken Word'
value: 'Spoken Word',
},
INTERVIEW: {
name: 'Interview',
description: `An interview release contains an interview, generally with
an artist.`,
value: 'Interview'
value: 'Interview',
},
AUDIOBOOK: {
name: 'Audiobook',
description: 'An audiobook is a book read by a narrator without music.',
value: 'Audiobook'
value: 'Audiobook',
},
LIVE: {
name: 'Live',
description: 'A release that was recorded live.',
value: 'Live'
value: 'Live',
},
REMIX: {
name: 'Remix',
description: `A release that was (re)mixed from previously released
material.`,
value: 'Remix'
value: 'Remix',
},
DJMIX: {
name: 'DJ-mix',
@ -159,7 +185,7 @@ after the other, each one modified so that they blend together into a continuous
flow of music. A DJ mix release requires that the recordings be modified in some
manner, and the DJ who does this modification is usually (although not always)
credited in a fairly prominent way.`,
value: 'DJ-mix'
value: 'DJ-mix',
},
MIXTAPE: {
name: 'Mixtape/Street',
@ -175,18 +201,18 @@ significant proportion of new material, including original production or
original vocals over top of other artists instrumentals. They are distinct from
demos in that they are designed for release directly to the public and fans, not
to labels.`,
value: 'Mixtape/Street'
value: 'Mixtape/Street',
},
DEMO: {
name: 'Demo',
description: `A release that was recorded for limited circulation or
reference use rather than for general public release.`,
value: 'Demo'
value: 'Demo',
},
NAT: {
name: 'Non-Album Track',
description: 'A non-album track (special case).',
value: 'NAT'
}
}
})
value: 'NAT',
},
},
});

View file

@ -1,21 +1,26 @@
import { GraphQLObjectType, GraphQLString, GraphQLBoolean } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import { Time } from './scalars'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { Time } from './scalars.js';
import {
fieldWithID,
id,
mbid,
name,
disambiguation,
aliases,
lifeSpan,
relationships,
tags,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { collections } from './collection.js';
import { lifeSpan } from './life-span.js';
import { rating } from './rating.js';
import { relationships } from './relationship.js';
import { tags } from './tag.js';
const Event = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLString, GraphQLBoolean } = GraphQL;
export const Event = new GraphQLObjectType({
name: 'Event',
description: `An [event](https://musicbrainz.org/doc/Event) refers to an
organised event which people can attend, and is relevant to MusicBrainz.
@ -30,25 +35,29 @@ Generally this means live performances, like concerts and festivals.`,
lifeSpan,
time: {
type: Time,
description: 'The start time of the event.'
description: 'The start time of the event.',
},
cancelled: {
type: GraphQLBoolean,
description: 'Whether or not the event took place.'
description: 'Whether or not the event took place.',
},
setlist: {
type: GraphQLString,
description: `A list of songs performed, optionally including links to
artists and works. See the [setlist documentation](https://musicbrainz.org/doc/Event/Setlist)
for syntax and examples.`
for syntax and examples.`,
},
...fieldWithID('type', {
description: 'What kind of event the event is, e.g. concert, festival, etc.'
description:
'What kind of event the event is, e.g. concert, festival, etc.',
}),
relationships,
tags
})
})
collections,
rating,
tags,
}),
});
export const EventConnection = connectionWithExtras(Event)
export default Event
export const EventConnection = connectionWithExtras(Event);
export const events = linkedQuery(EventConnection);

View file

@ -1,255 +1,156 @@
import dashify from 'dashify'
import pascalCase from 'pascalcase'
import {
GraphQLObjectType,
GraphQLString,
GraphQLInt,
GraphQLList,
GraphQLNonNull
} from 'graphql'
import {
import GraphQL from 'graphql';
import GraphQLRelay from 'graphql-relay';
import { MBID } from './scalars.js';
import { ReleaseGroupType, ReleaseStatus } from './enums.js';
import { resolveLinked } from '../resolvers.js';
import { toDashed, toPascal, toSingular, toPlural, toWords } from '../util.js';
const { GraphQLString, GraphQLInt, GraphQLList, GraphQLNonNull } = GraphQL;
const {
globalIdField,
connectionArgs,
connectionDefinitions,
connectionFromArray,
forwardConnectionArgs
} from 'graphql-relay'
import { MBID } from './scalars'
import { ReleaseGroupType, ReleaseStatus } from './enums'
import Alias from './alias'
import ArtistCredit from './artist-credit'
import { ArtistConnection } from './artist'
import { EventConnection } from './event'
import { LabelConnection } from './label'
import LifeSpan from './life-span'
import { PlaceConnection } from './place'
import { RecordingConnection } from './recording'
import { RelationshipConnection } from './relationship'
import { ReleaseConnection } from './release'
import { ReleaseGroupConnection } from './release-group'
import { TagConnection } from './tag'
import { WorkConnection } from './work'
import {
linkedResolver,
relationshipResolver,
subqueryResolver,
includeRelationships
} from '../resolvers'
forwardConnectionArgs,
} = GraphQLRelay;
export const toPascal = pascalCase
export const toDashed = dashify
const TYPE_NAMES = {
discid: 'Disc',
url: 'URL',
};
export function toPlural (name) {
return name.endsWith('s') ? name : name + 's'
export function resolveType(value, context, info) {
const typeName = TYPE_NAMES[value._type] || toPascal(value._type);
const typeMap = info.schema.getTypeMap();
return typeMap[typeName];
}
export function toSingular (name) {
return name.endsWith('s') && !/series/i.test(name) ? name.slice(0, -1) : name
export function resolveHyphenated(obj, args, context, info) {
const name = toDashed(info.fieldName);
return obj[name];
}
export function toWords (name) {
return toPascal(name).replace(/([^A-Z])?([A-Z]+)/g, (match, tail, head) => {
tail = tail ? tail + ' ' : ''
head = head.length > 1 ? head : head.toLowerCase()
return `${tail}${head}`
})
export function resolveWithFallback(keys) {
return (obj) => {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key in obj) {
return obj[key];
}
}
};
}
export function fieldWithID (name, config = {}) {
export function fieldWithID(name, config = {}) {
config = {
type: GraphQLString,
resolve: getHyphenated,
...config
}
const isPlural = config.type instanceof GraphQLList
const singularName = isPlural ? toSingular(name) : name
const idName = isPlural ? `${singularName}IDs` : `${name}ID`
const s = isPlural ? 's' : ''
resolve: resolveHyphenated,
...config,
};
const isPlural = config.type instanceof GraphQLList;
const singularName = isPlural ? toSingular(name) : name;
const idName = isPlural ? `${singularName}IDs` : `${name}ID`;
const s = isPlural ? 's' : '';
const idConfig = {
type: isPlural ? new GraphQLList(MBID) : MBID,
description: `The MBID${s} associated with the value${s} of the \`${name}\`
field.`,
resolve: getHyphenated
}
resolve: (entity, args, { loaders }) => {
const fieldName = toDashed(idName);
if (fieldName in entity) {
return entity[fieldName];
}
return loaders.lookup
.load([entity._type, entity.id])
.then((data) => data[fieldName]);
},
};
return {
[name]: config,
[idName]: idConfig
}
[idName]: idConfig,
};
}
export function getHyphenated (source, args, context, info) {
const name = dashify(info.fieldName)
return source[name]
export function createCollectionField(config) {
const typeName = toPlural(toWords(config.type.name.slice(0, -10)));
return {
...config,
description: `The list of ${typeName} found in this collection.`,
};
}
export function getFallback (keys) {
return (source) => {
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (key in source) {
return source[key]
}
}
}
}
export const id = globalIdField()
export const id = globalIdField();
export const mbid = {
type: new GraphQLNonNull(MBID),
description: 'The MBID of the entity.',
resolve: source => source.id
}
resolve: (entity) => entity.id,
};
export const name = {
type: GraphQLString,
description: 'The official name of the entity.'
}
description: 'The official name of the entity.',
};
export const sortName = {
type: GraphQLString,
description: `The string to use for the purpose of ordering by name (for
example, by moving articles like the to the end or a persons last name to
the front).`,
resolve: getHyphenated
}
resolve: resolveHyphenated,
};
export const title = {
type: GraphQLString,
description: 'The official title of the entity.'
}
description: 'The official title of the entity.',
};
export const disambiguation = {
type: GraphQLString,
description: 'A comment used to help distinguish identically named entitites.'
}
export const lifeSpan = {
type: LifeSpan,
description: `The begin and end dates of the entitys existence. Its exact
meaning depends on the type of entity.`,
resolve: getHyphenated
}
description:
'A comment used to help distinguish identically named entitites.',
};
function linkedQuery (connectionType, { args, ...config } = {}) {
const typeName = toPlural(toWords(connectionType.name.slice(0, -10)))
export function linkedQuery(connectionType, { args, ...config } = {}) {
const typeName = toPlural(toWords(connectionType.name.slice(0, -10)));
return {
type: connectionType,
description: `A list of ${typeName} linked to this entity.`,
args: {
...args,
...forwardConnectionArgs,
...args
},
resolve: linkedResolver(),
...config
}
resolve: resolveLinked,
...config,
};
}
export const relationship = {
type: RelationshipConnection,
description: 'A list of relationships between these two entity types.',
args: {
...connectionArgs,
direction: {
type: GraphQLString,
description: 'Filter by the relationship direction.'
},
...fieldWithID('type', {
description: 'Filter by the relationship type.'
})
},
resolve: relationshipResolver()
}
export const relationships = {
type: new GraphQLObjectType({
name: 'Relationships',
description: 'Lists of entity relationships for each entity type.',
fields: () => ({
areas: relationship,
artists: relationship,
events: relationship,
instruments: relationship,
labels: relationship,
places: relationship,
recordings: relationship,
releases: relationship,
releaseGroups: relationship,
series: relationship,
urls: relationship,
works: relationship
})
}),
description: 'Relationships between this entity and other entitites.',
resolve: (source, args, { loaders }, info) => {
if (source.relations != null) {
return source.relations
}
const entityType = toDashed(info.parentType.name)
const id = source.id
const params = includeRelationships({}, info)
return loaders.lookup.load([entityType, id, params]).then(entity => {
return entity.relations
})
}
}
export const aliases = {
type: new GraphQLList(Alias),
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are used to store
alternate names or misspellings.`,
resolve: subqueryResolver()
}
export const artistCredit = {
type: new GraphQLList(ArtistCredit),
description: 'The main credited artist(s).',
resolve: subqueryResolver()
}
export const artists = linkedQuery(ArtistConnection)
export const events = linkedQuery(EventConnection)
export const labels = linkedQuery(LabelConnection)
export const places = linkedQuery(PlaceConnection)
export const recordings = linkedQuery(RecordingConnection)
export const releases = linkedQuery(ReleaseConnection, {
args: {
type: {
type: new GraphQLList(ReleaseGroupType),
description: 'Filter by one or more release group types.'
},
status: {
type: new GraphQLList(ReleaseStatus),
description: 'Filter by one or more release statuses.'
}
}
})
export const releaseGroups = linkedQuery(ReleaseGroupConnection, {
args: {
type: {
type: new GraphQLList(ReleaseGroupType),
description: 'Filter by one or more release group types.'
}
}
})
export const tags = linkedQuery(TagConnection, {
resolve: subqueryResolver('tags', (value = [], args) => ({
totalCount: value.length,
...connectionFromArray(value, args)
}))
})
export const works = linkedQuery(WorkConnection)
export const totalCount = {
type: GraphQLInt,
description: `A count of the total number of items in this connection,
ignoring pagination.`
}
ignoring pagination.`,
};
export const score = {
type: GraphQLInt,
description: `The relevancy score (0100) assigned by the search engine, if
these results were found through a search.`
}
these results were found through a search.`,
};
export function connectionWithExtras (nodeType) {
export function connectionWithExtras(nodeType) {
return connectionDefinitions({
nodeType,
connectionFields: () => ({ totalCount }),
edgeFields: () => ({ score })
}).connectionType
connectionFields: () => ({
nodes: {
type: new GraphQLList(nodeType),
description: `A list of nodes in the connection (without going through the
\`edges\` field).`,
},
totalCount,
}),
edgeFields: () => ({ score }),
}).connectionType;
}
export const releaseGroupType = {
type: new GraphQLList(ReleaseGroupType),
description: 'Filter by one or more release group types.',
};
export const releaseStatus = {
type: new GraphQLList(ReleaseStatus),
description: 'Filter by one or more release statuses.',
};

View file

@ -1,17 +1,27 @@
export { MBID, DateType, IPI, URLString } from './scalars'
export { ReleaseGroupType, ReleaseStatus } from './enums'
export { default as Node } from './node'
export { default as Entity } from './entity'
export { default as Area, AreaConnection } from './area'
export { default as Artist, ArtistConnection } from './artist'
export { default as Event, EventConnection } from './event'
export { default as Instrument, InstrumentConnection } from './instrument'
export { default as Label, LabelConnection } from './label'
export { default as Place, PlaceConnection } from './place'
export { default as Recording, RecordingConnection } from './recording'
export { default as Release, ReleaseConnection } from './release'
export { default as ReleaseGroup, ReleaseGroupConnection } from './release-group'
export { default as Series, SeriesConnection } from './series'
export { default as Tag, TagConnection } from './tag'
export { default as URL, URLConnection } from './url'
export { default as Work, WorkConnection } from './work'
export {
DateType,
DiscID,
IPI,
ISRC,
ISWC,
MBID,
URLString,
} from './scalars.js';
export { ReleaseGroupType, ReleaseStatus } from './enums.js';
export { Node } from './node.js';
export { Entity, EntityConnection } from './entity.js';
export { Area, AreaConnection } from './area.js';
export { Artist, ArtistConnection } from './artist.js';
export { Collection, CollectionConnection } from './collection.js';
export { Disc } from './disc.js';
export { Event, EventConnection } from './event.js';
export { Instrument, InstrumentConnection } from './instrument.js';
export { Label, LabelConnection } from './label.js';
export { Place, PlaceConnection } from './place.js';
export { Recording, RecordingConnection } from './recording.js';
export { Release, ReleaseConnection } from './release.js';
export { ReleaseGroup, ReleaseGroupConnection } from './release-group.js';
export { Series, SeriesConnection } from './series.js';
export { Tag, TagConnection } from './tag.js';
export { URL, URLConnection } from './url.js';
export { Work, WorkConnection } from './work.js';

View file

@ -1,19 +1,23 @@
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import {
fieldWithID,
id,
mbid,
name,
disambiguation,
aliases,
relationships,
tags,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { collections } from './collection.js';
import { relationships } from './relationship.js';
import { tags } from './tag.js';
const Instrument = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLString } = GraphQL;
export const Instrument = new GraphQLObjectType({
name: 'Instrument',
description: `[Instruments](https://musicbrainz.org/doc/Instrument) are
devices created or adapted to make musical sounds. Instruments are primarily
@ -28,17 +32,19 @@ used in relationships between two other entities.`,
description: {
type: GraphQLString,
description: `A brief description of the main characteristics of the
instrument.`
instrument.`,
},
...fieldWithID('type', {
description: `The type categorises the instrument by the way the sound is
created, similar to the [Hornbostel-Sachs](https://en.wikipedia.org/wiki/Hornbostel%E2%80%93Sachs)
classification.`
classification.`,
}),
relationships,
tags
})
})
collections,
tags,
}),
});
export const InstrumentConnection = connectionWithExtras(Instrument)
export default Instrument
export const InstrumentConnection = connectionWithExtras(Instrument);
export const instruments = linkedQuery(InstrumentConnection);

View file

@ -1,29 +1,29 @@
import {
GraphQLObjectType,
GraphQLList,
GraphQLString,
GraphQLInt
} from 'graphql/type'
import Node from './node'
import Entity from './entity'
import { IPI } from './scalars'
import Area from './area'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { IPI } from './scalars.js';
import { Area } from './area.js';
import {
id,
mbid,
name,
sortName,
disambiguation,
aliases,
lifeSpan,
releases,
relationships,
tags,
fieldWithID,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { collections } from './collection.js';
import { lifeSpan } from './life-span.js';
import { tags } from './tag.js';
import { rating } from './rating.js';
import { relationships } from './relationship.js';
import { releases } from './release.js';
const Label = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLList, GraphQLString, GraphQLInt } = GraphQL;
export const Label = new GraphQLObjectType({
name: 'Label',
description: `[Labels](https://musicbrainz.org/doc/Label) represent mostly
(but not only) imprints. To a lesser extent, a label entity may be created to
@ -38,32 +38,35 @@ represent a record company.`,
aliases,
country: {
type: GraphQLString,
description: 'The country of origin for the label.'
description: 'The country of origin for the label.',
},
area: {
type: Area,
description: 'The area in which the label is based.'
description: 'The area in which the label is based.',
},
lifeSpan,
labelCode: {
type: GraphQLInt,
description: `The [“LC” code](https://musicbrainz.org/doc/Label/Label_Code)
of the label.`
of the label.`,
},
ipis: {
type: new GraphQLList(IPI),
description: `List of IPI (interested party information) codes for the
label.`
description: `List of [Interested Parties Information](https://musicbrainz.org/doc/IPI)
codes for the label.`,
},
...fieldWithID('type', {
description: `A type describing the main activity of the label, e.g.
imprint, production, distributor, rights society, etc.`
imprint, production, distributor, rights society, etc.`,
}),
releases,
relationships,
tags
})
})
collections,
rating,
tags,
}),
});
export const LabelConnection = connectionWithExtras(Label)
export default Label
export const LabelConnection = connectionWithExtras(Label);
export const labels = linkedQuery(LabelConnection);

View file

@ -1,22 +1,32 @@
import { GraphQLObjectType, GraphQLBoolean } from 'graphql/type'
import { DateType } from './scalars'
import GraphQL from 'graphql';
import { DateType } from './scalars.js';
import { resolveHyphenated } from './helpers.js';
export default new GraphQLObjectType({
const { GraphQLObjectType, GraphQLBoolean } = GraphQL;
export const LifeSpan = new GraphQLObjectType({
name: 'LifeSpan',
description: `Fields indicating the begin and end date of an entitys
lifetime, including whether it has ended (even if the date is unknown).`,
fields: () => ({
begin: {
type: DateType,
description: 'The start date of the entitys life span.'
description: 'The start date of the entitys life span.',
},
end: {
type: DateType,
description: 'The end date of the entitys life span.'
description: 'The end date of the entitys life span.',
},
ended: {
type: GraphQLBoolean,
description: 'Whether or not the entitys life span has ended.'
}
})
})
description: 'Whether or not the entitys life span has ended.',
},
}),
});
export const lifeSpan = {
type: LifeSpan,
description: `The begin and end dates of the entitys existence. Its exact
meaning depends on the type of entity.`,
resolve: resolveHyphenated,
};

49
src/types/media.js Normal file
View file

@ -0,0 +1,49 @@
import GraphQL from 'graphql';
import { Disc } from './disc.js';
import { Track } from './track.js';
import { resolveHyphenated, fieldWithID } from './helpers.js';
import { createSubqueryResolver } from '../resolvers.js';
const { GraphQLObjectType, GraphQLList, GraphQLString, GraphQLInt } = GraphQL;
export const Media = new GraphQLObjectType({
name: 'Medium',
description: `A medium is the actual physical medium the audio content is
stored upon. This means that each CD in a multi-disc release will be entered as
separate mediums within the release, and that both sides of a vinyl record or
cassette will exist on one medium. Mediums have a format (e.g. CD, DVD, vinyl,
cassette) and can optionally also have a title.`,
fields: () => ({
title: {
type: GraphQLString,
description: 'The title of this particular medium.',
},
...fieldWithID('format', {
description: `The [format](https://musicbrainz.org/doc/Release/Format) of
the medium (e.g. CD, DVD, vinyl, cassette).`,
}),
position: {
type: GraphQLInt,
description: `The order of this medium in the release (for example, in a
multi-disc release).`,
},
trackCount: {
type: GraphQLInt,
description: 'The number of audio tracks on this medium.',
resolve: resolveHyphenated,
},
discs: {
type: new GraphQLList(Disc),
description:
'A list of physical discs and their disc IDs for this medium.',
},
tracks: {
type: new GraphQLList(Track),
description: 'The list of tracks on the given media.',
resolve: createSubqueryResolver({
inc: 'recordings',
key: 'tracks',
}),
},
}),
});

View file

@ -1,21 +1,17 @@
import { nodeDefinitions, fromGlobalId } from 'graphql-relay'
import { toDashed } from './helpers'
import GraphQLRelay from 'graphql-relay';
import { toDashed } from '../util.js';
import { resolveType } from './helpers.js';
const { nodeDefinitions, fromGlobalId } = GraphQLRelay;
const { nodeInterface, nodeField } = nodeDefinitions(
(globalID, { loaders }) => {
const { type, id } = fromGlobalId(globalID)
const entityType = toDashed(type)
return loaders.lookup.load([entityType, id])
const { type, id } = fromGlobalId(globalID);
const entityType = toDashed(type);
return loaders.lookup.load([entityType, id]);
},
(obj) => {
try {
return require(`./${obj._type}`).default
} catch (err) {
console.error(err)
return null
}
}
)
resolveType
);
export default nodeInterface
export { nodeInterface, nodeField }
export const Node = nodeInterface;
export { nodeInterface, nodeField };

View file

@ -1,21 +1,25 @@
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import { Degrees } from './scalars'
import Area from './area'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { Degrees } from './scalars.js';
import { Area } from './area.js';
import {
id,
mbid,
name,
disambiguation,
aliases,
lifeSpan,
events,
fieldWithID,
relationships,
tags,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { collections } from './collection.js';
import { events } from './event.js';
import { lifeSpan } from './life-span.js';
import { relationships } from './relationship.js';
import { tags } from './tag.js';
const { GraphQLObjectType, GraphQLString } = GraphQL;
export const Coordinates = new GraphQLObjectType({
name: 'Coordinates',
@ -23,16 +27,17 @@ export const Coordinates = new GraphQLObjectType({
fields: () => ({
latitude: {
type: Degrees,
description: 'The northsouth position of a point on the Earths surface.'
description:
'The northsouth position of a point on the Earths surface.',
},
longitude: {
type: Degrees,
description: 'The eastwest position of a point on the Earths surface.'
}
})
})
description: 'The eastwest position of a point on the Earths surface.',
},
}),
});
const Place = new GraphQLObjectType({
export const Place = new GraphQLObjectType({
name: 'Place',
description: `A [place](https://musicbrainz.org/doc/Place) is a venue, studio,
or other place where music is performed, recorded, engineered, etc.`,
@ -46,27 +51,29 @@ or other place where music is performed, recorded, engineered, etc.`,
address: {
type: GraphQLString,
description: `The address describes the location of the place using the
standard addressing format for the country it is located in.`
standard addressing format for the country it is located in.`,
},
area: {
type: Area,
description: `The area entity representing the area, such as the city, in
which the place is located.`
which the place is located.`,
},
coordinates: {
type: Coordinates,
description: 'The geographic coordinates of the place.'
description: 'The geographic coordinates of the place.',
},
lifeSpan,
...fieldWithID('type', {
description: `The type categorises the place based on its primary
function.`
function.`,
}),
events,
relationships,
tags
})
})
collections,
tags,
}),
});
export const PlaceConnection = connectionWithExtras(Place)
export default Place
export const PlaceConnection = connectionWithExtras(Place);
export const places = linkedQuery(PlaceConnection);

29
src/types/rating.js Normal file
View file

@ -0,0 +1,29 @@
import GraphQL from 'graphql';
import { createSubqueryResolver } from '../resolvers.js';
const { GraphQLObjectType, GraphQLNonNull, GraphQLInt, GraphQLFloat } = GraphQL;
export const Rating = new GraphQLObjectType({
name: 'Rating',
description: `[Ratings](https://musicbrainz.org/doc/Rating_System) allow users
to rate MusicBrainz entities. User may assign a value between 1 and 5; these
values are then aggregated by the server to compute an average community rating
for the entity.`,
fields: () => ({
voteCount: {
type: new GraphQLNonNull(GraphQLInt),
description: 'The number of votes that have contributed to the rating.',
resolve: (rating) => rating['votes-count'],
},
value: {
type: GraphQLFloat,
description: 'The average rating value based on the aggregated votes.',
},
}),
});
export const rating = {
type: Rating,
description: 'The rating users have given to this entity.',
resolve: createSubqueryResolver({ inc: 'ratings' }),
};

View file

@ -1,21 +1,27 @@
import { GraphQLObjectType, GraphQLInt, GraphQLBoolean } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { Duration, ISRC } from './scalars.js';
import {
id,
mbid,
title,
disambiguation,
aliases,
artistCredit,
artists,
releases,
relationships,
tags,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { artists } from './artist.js';
import { artistCredit, artistCredits } from './artist-credit.js';
import { collections } from './collection.js';
import { tags } from './tag.js';
import { rating } from './rating.js';
import { relationships } from './relationship.js';
import { releases } from './release.js';
const Recording = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLList, GraphQLBoolean } = GraphQL;
export const Recording = new GraphQLObjectType({
name: 'Recording',
description: `A [recording](https://musicbrainz.org/doc/Recording) is an
entity in MusicBrainz which can be linked to tracks on releases. Each track must
@ -37,21 +43,41 @@ or mixing.`,
disambiguation,
aliases,
artistCredit,
artistCredits,
isrcs: {
type: new GraphQLList(ISRC),
description: `A list of [International Standard Recording Codes](https://musicbrainz.org/doc/ISRC)
(ISRCs) for this recording.`,
resolve: (source, args, context) => {
if (source.isrcs) {
return source.isrcs;
}
// TODO: Add support for parent entities knowing to include this `inc`
// parameter in their own calls by inspecting what fields are requested
// or batching things at the loader level.
return context.loaders.lookup
.load(['recording', source.id, { inc: 'isrcs' }])
.then((recording) => recording.isrcs);
},
},
length: {
type: GraphQLInt,
type: Duration,
description: `An approximation to the length of the recording, calculated
from the lengths of the tracks using it.`
from the lengths of the tracks using it.`,
},
video: {
type: GraphQLBoolean,
description: 'Whether this is a video recording.'
description: 'Whether this is a video recording.',
},
artists,
releases,
relationships,
tags
})
})
collections,
rating,
tags,
}),
});
export const RecordingConnection = connectionWithExtras(Recording)
export default Recording
export const RecordingConnection = connectionWithExtras(Recording);
export const recordings = linkedQuery(RecordingConnection);

View file

@ -1,19 +1,25 @@
import GraphQL from 'graphql';
import GraphQLRelay from 'graphql-relay';
import { DateType } from './scalars.js';
import { Entity } from './entity.js';
import {
resolveHyphenated,
fieldWithID,
connectionWithExtras,
} from './helpers.js';
import { resolveRelationship, includeRelationships } from '../resolvers.js';
import { toDashed } from '../util.js';
const {
GraphQLObjectType,
GraphQLNonNull,
GraphQLString,
GraphQLList,
GraphQLBoolean
} from 'graphql/type'
import { DateType } from './scalars'
import Entity from './entity'
import {
getHyphenated,
fieldWithID,
connectionWithExtras
} from './helpers'
GraphQLBoolean,
} = GraphQL;
const { connectionArgs } = GraphQLRelay;
const Relationship = new GraphQLObjectType({
export const Relationship = new GraphQLObjectType({
name: 'Relationship',
description: `[Relationships](https://musicbrainz.org/doc/Relationships) are a
way to represent all the different ways in which entities are connected to each
@ -22,61 +28,112 @@ other and to URLs outside MusicBrainz.`,
target: {
type: new GraphQLNonNull(Entity),
description: 'The target entity.',
resolve: source => {
const targetType = source['target-type']
const target = source[targetType]
target._type = targetType.replace('_', '-')
return target
}
resolve: (source) => {
const targetType = source['target-type'];
const target = source[targetType];
target._type = targetType.replace('_', '-');
return target;
},
},
direction: {
type: new GraphQLNonNull(GraphQLString),
description: 'The direction of the relationship.'
description: 'The direction of the relationship.',
},
targetType: {
type: new GraphQLNonNull(GraphQLString),
description: 'The type of entity on the receiving end of the relationship.',
resolve: getHyphenated
description:
'The type of entity on the receiving end of the relationship.',
resolve: resolveHyphenated,
},
sourceCredit: {
type: GraphQLString,
description: `How the source entity was actually credited, if different
from its main (performance) name.`,
resolve: getHyphenated
resolve: resolveHyphenated,
},
targetCredit: {
type: GraphQLString,
description: `How the target entity was actually credited, if different
from its main (performance) name.`,
resolve: getHyphenated
resolve: resolveHyphenated,
},
begin: {
type: DateType,
description: 'The date on which the relationship became applicable.'
description: 'The date on which the relationship became applicable.',
},
end: {
type: DateType,
description: 'The date on which the relationship became no longer applicable.'
description:
'The date on which the relationship became no longer applicable.',
},
ended: {
type: GraphQLBoolean,
description: 'Whether the relationship still applies.'
description: 'Whether the relationship still applies.',
},
attributes: {
type: new GraphQLList(GraphQLString),
description: `Attributes which modify the relationship. There is a [list
of all attributes](https://musicbrainz.org/relationship-attributes), but the
attributes which are available, and how they should be used, depends on the
relationship type.`
relationship type.`,
},
// There doesn't seem to be any documentation for the `attribute-values`
// field.
// attributeValues: {},
...fieldWithID('type', {
description: 'The type of relationship.'
})
})
})
description: 'The type of relationship.',
}),
}),
});
export const RelationshipConnection = connectionWithExtras(Relationship)
export default Relationship
export const RelationshipConnection = connectionWithExtras(Relationship);
export const relationship = {
type: RelationshipConnection,
description: 'A list of relationships between these two entity types.',
args: {
direction: {
type: GraphQLString,
description: 'Filter by the relationship direction.',
},
...fieldWithID('type', {
description: 'Filter by the relationship type.',
}),
...connectionArgs,
},
resolve: resolveRelationship,
};
export const relationships = {
type: new GraphQLObjectType({
name: 'Relationships',
description: 'Lists of entity relationships for each entity type.',
fields: () => ({
areas: relationship,
artists: relationship,
events: relationship,
instruments: relationship,
labels: relationship,
places: relationship,
recordings: relationship,
releases: relationship,
releaseGroups: relationship,
series: relationship,
urls: relationship,
works: relationship,
}),
}),
description: 'Relationships between this entity and other entitites.',
resolve: (entity, args, { loaders }, info) => {
let promise;
if (entity.relations != null) {
promise = Promise.resolve(entity);
} else {
const entityType = toDashed(info.parentType.name);
const id = entity.id;
const params = includeRelationships({}, info);
promise = loaders.lookup.load([entityType, id, params]);
}
return promise.then((entity) => entity.relations);
},
};

View file

@ -1,13 +1,15 @@
import { GraphQLObjectType } from 'graphql/type'
import { DateType } from './scalars'
import Area from './area'
import GraphQL from 'graphql';
import { DateType } from './scalars.js';
import { Area } from './area.js';
export default new GraphQLObjectType({
const { GraphQLObjectType } = GraphQL;
export const ReleaseEvent = new GraphQLObjectType({
name: 'ReleaseEvent',
description: `Date on which a release was issued in a country/region with a
particular label, catalog number, barcode, and what release format was used.`,
description: `The date on which a release was issued in a country/region with
a particular label, catalog number, barcode, and format.`,
fields: () => ({
area: { type: Area },
date: { type: DateType }
})
})
date: { type: DateType },
}),
});

View file

@ -1,25 +1,31 @@
import { GraphQLObjectType, GraphQLList } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import { DateType } from './scalars'
import { ReleaseGroupType } from './enums'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { DateType } from './scalars.js';
import { ReleaseGroupType } from './enums.js';
import {
id,
mbid,
title,
disambiguation,
aliases,
artistCredit,
artists,
releases,
relationships,
tags,
fieldWithID,
getHyphenated,
connectionWithExtras
} from './helpers'
releaseGroupType,
resolveHyphenated,
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { artistCredit, artistCredits } from './artist-credit.js';
import { artists } from './artist.js';
import { releases } from './release.js';
import { relationships } from './relationship.js';
import { collections } from './collection.js';
import { rating } from './rating.js';
import { tags } from './tag.js';
const ReleaseGroup = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLList } = GraphQL;
export const ReleaseGroup = new GraphQLObjectType({
name: 'ReleaseGroup',
description: `A [release group](https://musicbrainz.org/doc/Release_Group) is
used to group several different releases into a single logical entity. Every
@ -37,29 +43,37 @@ album it doesnt matter how many CDs or editions/versions it had.`,
disambiguation,
aliases,
artistCredit,
artistCredits,
firstReleaseDate: {
type: DateType,
description: 'The date of the earliest release in the group.',
resolve: getHyphenated
resolve: resolveHyphenated,
},
...fieldWithID('primaryType', {
type: ReleaseGroupType,
description: `The [type](https://musicbrainz.org/doc/Release_Group/Type)
of a release group describes what kind of releases the release group represents,
e.g. album, single, soundtrack, compilation, etc. A release group can have a
main type and an unspecified number of additional types.`
main type and an unspecified number of additional types.`,
}),
...fieldWithID('secondaryTypes', {
type: new GraphQLList(ReleaseGroupType),
description: `Additional [types](https://musicbrainz.org/doc/Release_Group/Type)
that apply to this release group.`
that apply to this release group.`,
}),
artists,
releases,
relationships,
tags
})
})
collections,
rating,
tags,
}),
});
export const ReleaseGroupConnection = connectionWithExtras(ReleaseGroup)
export default ReleaseGroup
export const ReleaseGroupConnection = connectionWithExtras(ReleaseGroup);
export const releaseGroups = linkedQuery(ReleaseGroupConnection, {
args: {
type: releaseGroupType,
},
});

View file

@ -1,28 +1,35 @@
import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import { DateType } from './scalars'
import { ReleaseStatus } from './enums'
import ReleaseEvent from './release-event'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { ASIN, DateType } from './scalars.js';
import { Media } from './media.js';
import { ReleaseStatus } from './enums.js';
import { ReleaseEvent } from './release-event.js';
import {
id,
mbid,
title,
disambiguation,
aliases,
artistCredit,
artists,
labels,
recordings,
releaseGroups,
relationships,
tags,
fieldWithID,
getHyphenated,
connectionWithExtras
} from './helpers'
releaseGroupType,
releaseStatus,
resolveHyphenated,
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { artistCredit, artistCredits } from './artist-credit.js';
import { artists } from './artist.js';
import { collections } from './collection.js';
import { labels } from './label.js';
import { recordings } from './recording.js';
import { relationships } from './relationship.js';
import { releaseGroups } from './release-group.js';
import { tags } from './tag.js';
const Release = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLString, GraphQLList } = GraphQL;
export const Release = new GraphQLObjectType({
name: 'Release',
description: `A [release](https://musicbrainz.org/doc/Release) represents the
unique release (i.e. issuing) of a product on a specific date with specific
@ -37,51 +44,68 @@ MusicBrainz as one release.`,
disambiguation,
aliases,
artistCredit,
artistCredits,
releaseEvents: {
type: new GraphQLList(ReleaseEvent),
description: 'The release events for this release.',
resolve: getHyphenated
resolve: resolveHyphenated,
},
date: {
type: DateType,
description: `The [release date](https://musicbrainz.org/doc/Release/Date)
is the date in which a release was made available through some sort of
distribution mechanism.`
distribution mechanism.`,
},
country: {
type: GraphQLString,
description: 'The country in which the release was issued.'
description: 'The country in which the release was issued.',
},
asin: {
type: ASIN,
description: `The [Amazon Standard Identification Number](https://musicbrainz.org/doc/ASIN)
of the release.`,
},
barcode: {
type: GraphQLString,
description: `The [barcode](https://en.wikipedia.org/wiki/Barcode), if the
release has one. The most common types found on releases are 12-digit
[UPCs](https://en.wikipedia.org/wiki/Universal_Product_Code) and 13-digit
[EANs](https://en.wikipedia.org/wiki/International_Article_Number).`
[EANs](https://en.wikipedia.org/wiki/International_Article_Number).`,
},
...fieldWithID('status', {
type: ReleaseStatus,
description: 'The status describes how “official” a release is.'
description: 'The status describes how “official” a release is.',
}),
...fieldWithID('packaging', {
description: `The physical packaging that accompanies the release. See
the [list of packaging](https://musicbrainz.org/doc/Release/Packaging) for more
information.`
information.`,
}),
quality: {
type: GraphQLString,
description: `Data quality indicates how good the data for a release is.
It is not a mark of how good or bad the music itself is for that, use
[ratings](https://musicbrainz.org/doc/Rating_System).`
[ratings](https://musicbrainz.org/doc/Rating_System).`,
},
media: {
type: new GraphQLList(Media),
description: 'The media on which the release was distributed.',
},
artists,
labels,
recordings,
releaseGroups,
relationships,
tags
})
})
collections,
tags,
}),
});
export const ReleaseConnection = connectionWithExtras(Release)
export default Release
export const ReleaseConnection = connectionWithExtras(Release);
export const releases = linkedQuery(ReleaseConnection, {
args: {
type: releaseGroupType,
status: releaseStatus,
},
});

View file

@ -1,156 +1,178 @@
import { Kind } from 'graphql/language'
import { GraphQLScalarType } from 'graphql/type'
import GraphQL from 'graphql';
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
const { Kind, GraphQLScalarType } = GraphQL;
function validateMBID (value) {
function createScalar(config) {
return new GraphQLScalarType({
serialize: (value) => value,
parseValue: (value) => value,
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return ast.value;
}
return undefined;
},
...config,
});
}
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const locale = /^([a-z]{2})(_[A-Z]{2})?(\.[a-zA-Z0-9-]+)?$/;
// Be extremely lenient; just prevent major input errors.
const url = /^\w+:\/\/[\w-]+\.\w+/;
function validateMBID(value) {
if (typeof value === 'string' && uuid.test(value)) {
return value
return value;
}
throw new TypeError(`Malformed MBID: ${value}`)
throw new TypeError(`Malformed MBID: ${value}`);
}
function validatePositive (value) {
function validatePositive(value) {
if (value >= 0) {
return value
return value;
}
throw new TypeError(`Expected positive value: ${value}`)
throw new TypeError(`Expected positive value: ${value}`);
}
export const DateType = new GraphQLScalarType({
function validateLocale(value) {
if (typeof value === 'string' && locale.test(value)) {
return value;
}
throw new TypeError(`Malformed locale: ${value}`);
}
function validateURL(value) {
if (typeof value === 'string' && url.test(value)) {
return value;
}
throw new TypeError(`Malformed URL: ${value}`);
}
export const ASIN = createScalar({
name: 'ASIN',
description: `An [Amazon Standard Identification Number](https://musicbrainz.org/doc/ASIN)
(ASIN) is a 10-character alphanumeric unique identifier assigned by Amazon.com
and its partners for product identification within the Amazon organization.`,
});
export const DateType = createScalar({
name: 'Date',
description:
'Year, month (optional), and day (optional) in YYYY-MM-DD format.',
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
if (ast.kind === Kind.STRING) {
return ast.value
}
return null
}
})
});
export const Degrees = new GraphQLScalarType({
export const Degrees = createScalar({
name: 'Degrees',
description: 'Decimal degrees, used for latitude and longitude.',
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
if (ast.kind === Kind.STRING) {
return ast.value
}
return null
}
})
});
export const Duration = new GraphQLScalarType({
export const DiscID = createScalar({
name: 'DiscID',
description: `[Disc ID](https://musicbrainz.org/doc/Disc_ID) is the code
number which MusicBrainz uses to link a physical CD to a [release](https://musicbrainz.org/doc/Release)
listing.
A release may have any number of disc IDs, and a disc ID may be linked to
multiple releases. This is because disc ID calculation involves a hash of the
frame offsets of the CD tracks.
Different pressing of a CD often have slightly different frame offsets, and
hence different disc IDs.
Conversely, two different CDs may happen to have exactly the same set of frame
offsets and hence the same disc ID.`,
});
export const Duration = createScalar({
name: 'Duration',
description: 'A length of time, in milliseconds.',
serialize: validatePositive,
parseValue: validatePositive,
parseLiteral (ast) {
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return validatePositive(parseInt(ast.value, 10))
return validatePositive(parseInt(ast.value, 10));
}
return null
}
})
return undefined;
},
});
export const IPI = new GraphQLScalarType({
export const IPI = createScalar({
name: 'IPI',
description: `An [IPI](https://musicbrainz.org/doc/IPI) (interested party
information) code is an identifying number assigned by the CISAC database for
musical rights management.`,
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
if (ast.kind === Kind.STRING) {
return ast.value
}
return null
}
})
description: `An [Interested Parties Information](https://musicbrainz.org/doc/IPI)
(IPI) code is an identifying number assigned by the CISAC database for musical
rights management.`,
});
export const ISNI = new GraphQLScalarType({
export const ISNI = createScalar({
name: 'ISNI',
description: `The [International Standard Name Identifier](https://musicbrainz.org/doc/ISNI)
(ISNI) is an ISO standard for uniquely identifying the public identities of
contributors to media content.`,
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
if (ast.kind === Kind.STRING) {
return ast.value
}
return null
}
})
});
export const ISWC = new GraphQLScalarType({
export const ISRC = createScalar({
name: 'ISRC',
description: `The [International Standard Recording Code](https://musicbrainz.org/doc/ISRC)
(ISRC) is an identification system for audio and music video recordings. It is
standarized by the [IFPI](http://www.ifpi.org/) in ISO 3901:2001 and used by
IFPI members to assign a unique identifier to every distinct sound recording
they release. An ISRC identifies a particular [sound recording](https://musicbrainz.org/doc/Recording),
not the song itself. Therefore, different recordings, edits, remixes and
remasters of the same song will each be assigned their own ISRC. However, note
that same recording should carry the same ISRC in all countries/territories.
Songs are identified by analogous [International Standard Musical Work Codes](https://musicbrainz.org/doc/ISWC)
(ISWCs).`,
});
export const ISWC = createScalar({
name: 'ISWC',
description: `The [International Standard Musical Work Code](https://musicbrainz.org/doc/ISWC)
(ISWC) is an ISO standard similar to ISBNs for identifying musical works /
compositions.`,
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
if (ast.kind === Kind.STRING) {
return ast.value
}
return null
}
})
});
export const Locale = new GraphQLScalarType({
export const Locale = createScalar({
name: 'Locale',
description: 'Language code, optionally with country and encoding.',
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
serialize: validateLocale,
parseValue: validateLocale,
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return ast.value
return validateLocale(ast.value);
}
return null
}
})
return undefined;
},
});
export const Time = new GraphQLScalarType({
name: 'Time',
description: 'A time of day, in 24-hour hh:mm notation.',
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
if (ast.kind === Kind.STRING) {
return ast.value
}
return null
}
})
export const URLString = new GraphQLScalarType({
name: 'URLString',
description: 'A web address.',
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
if (ast.kind === Kind.STRING) {
return ast.value
}
return null
}
})
export const MBID = new GraphQLScalarType({
export const MBID = createScalar({
name: 'MBID',
description: `The MBID scalar represents MusicBrainz identifiers, which are
36-character UUIDs.`,
serialize: validateMBID,
parseValue: validateMBID,
parseLiteral (ast) {
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return validateMBID(ast.value)
return validateMBID(ast.value);
}
return null
}
})
return undefined;
},
});
export const Time = createScalar({
name: 'Time',
description: 'A time of day, in 24-hour hh:mm notation.',
});
export const URLString = createScalar({
name: 'URLString',
description: 'A web address.',
serialize: validateURL,
parseValue: validateURL,
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return validateURL(ast.value);
}
return undefined;
},
});

View file

@ -1,18 +1,22 @@
import { GraphQLObjectType } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import {
id,
mbid,
name,
disambiguation,
relationships,
tags,
fieldWithID,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { collections } from './collection.js';
import { relationships } from './relationship.js';
import { tags } from './tag.js';
const Series = new GraphQLObjectType({
const { GraphQLObjectType } = GraphQL;
export const Series = new GraphQLObjectType({
name: 'Series',
description: `A [series](https://musicbrainz.org/doc/Series) is a sequence of
separate release groups, releases, recordings, works or events with a common
@ -25,12 +29,14 @@ theme.`,
disambiguation,
...fieldWithID('type', {
description: `The type primarily describes what type of entity the series
contains.`
contains.`,
}),
relationships,
tags
})
})
collections,
tags,
}),
});
export const SeriesConnection = connectionWithExtras(Series)
export default Series
export const SeriesConnection = connectionWithExtras(Series);
export const series = linkedQuery(SeriesConnection);

View file

@ -1,12 +1,17 @@
import {
import GraphQL from 'graphql';
import GraphQLRelay from 'graphql-relay';
import { connectionWithExtras, linkedQuery } from './helpers.js';
import { createSubqueryResolver } from '../resolvers.js';
const {
GraphQLObjectType,
GraphQLNonNull,
GraphQLString,
GraphQLInt
} from 'graphql/type'
import { connectionWithExtras } from './helpers'
GraphQLInt,
} = GraphQL;
const { connectionFromArray } = GraphQLRelay;
const Tag = new GraphQLObjectType({
export const Tag = new GraphQLObjectType({
name: 'Tag',
description: `[Tags](https://musicbrainz.org/tags) are a way to mark entities
with extra information for example, the genres that apply to an artist,
@ -14,14 +19,24 @@ release, or recording.`,
fields: () => ({
name: {
type: new GraphQLNonNull(GraphQLString),
description: 'The tag label.'
description: 'The tag label.',
},
count: {
type: GraphQLInt,
description: 'How many times this tag has been applied to the entity.'
}
})
})
description: 'How many times this tag has been applied to the entity.',
},
}),
});
export const TagConnection = connectionWithExtras(Tag)
export default Tag
export const TagConnection = connectionWithExtras(Tag);
export const tags = linkedQuery(TagConnection, {
resolve: createSubqueryResolver({}, (value = [], args) => {
const connection = connectionFromArray(value, args);
return {
nodes: connection.edges.map((edge) => edge.node),
totalCount: value.length,
...connection,
};
}),
});

44
src/types/track.js Normal file
View file

@ -0,0 +1,44 @@
import GraphQL from 'graphql';
import { Entity } from './entity.js';
import { Duration } from './scalars.js';
import { Recording } from './recording.js';
import { mbid, title } from './helpers.js';
const { GraphQLObjectType, GraphQLInt, GraphQLString } = GraphQL;
export const Track = new GraphQLObjectType({
name: 'Track',
description: `A track is the way a recording is represented on a particular
release (or, more exactly, on a particular medium). Every track has a title
(see the guidelines for titles) and is credited to one or more artists.`,
interfaces: () => [Entity],
fields: () => ({
mbid,
title,
position: {
type: GraphQLInt,
description: `The tracks position on the overall release (including all
tracks from all discs).`,
},
number: {
type: GraphQLString,
description: `The track number, which may include information about the
disc or side it appears on, e.g. A1 or B3.`,
},
length: {
type: Duration,
description: 'The length of the track.',
},
recording: {
type: Recording,
description: 'The recording that appears on the track.',
resolve: (source) => {
const { recording } = source;
if (recording) {
recording._type = 'recording';
}
return recording;
},
},
}),
});

View file

@ -1,10 +1,13 @@
import { GraphQLObjectType, GraphQLNonNull } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import { URLString } from './scalars'
import { id, mbid, relationships, connectionWithExtras } from './helpers'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import { URLString } from './scalars.js';
import { id, mbid, connectionWithExtras } from './helpers.js';
import { relationships } from './relationship.js';
const URL = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLNonNull } = GraphQL;
export const URL = new GraphQLObjectType({
name: 'URL',
description: `A [URL](https://musicbrainz.org/doc/URL) pointing to a resource
external to MusicBrainz, i.e. an official homepage, a site where music can be
@ -15,11 +18,10 @@ acquired, an entry in another database, etc.`,
mbid,
resource: {
type: new GraphQLNonNull(URLString),
description: 'The actual URL string.'
description: 'The actual URL string.',
},
relationships
})
})
relationships,
}),
});
export const URLConnection = connectionWithExtras(URL)
export default URL
export const URLConnection = connectionWithExtras(URL);

View file

@ -1,20 +1,25 @@
import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type'
import Node from './node'
import Entity from './entity'
import GraphQL from 'graphql';
import { Node } from './node.js';
import { Entity } from './entity.js';
import {
id,
mbid,
title,
disambiguation,
aliases,
artists,
relationships,
tags,
fieldWithID,
connectionWithExtras
} from './helpers'
connectionWithExtras,
linkedQuery,
} from './helpers.js';
import { aliases } from './alias.js';
import { artists } from './artist.js';
import { collections } from './collection.js';
import { rating } from './rating.js';
import { relationships } from './relationship.js';
import { tags } from './tag.js';
const Work = new GraphQLObjectType({
const { GraphQLObjectType, GraphQLString, GraphQLList } = GraphQL;
export const Work = new GraphQLObjectType({
name: 'Work',
description: `A [work](https://musicbrainz.org/doc/Work) is a distinct
intellectual or artistic creation, which can be expressed in the form of one or
@ -29,20 +34,23 @@ more audio recordings.`,
iswcs: {
type: new GraphQLList(GraphQLString),
description: `A list of [ISWCs](https://musicbrainz.org/doc/ISWC) assigned
to the work by copyright collecting agencies.`
to the work by copyright collecting agencies.`,
},
language: {
type: GraphQLString,
description: 'The language in which the work was originally written.'
description: 'The language in which the work was originally written.',
},
...fieldWithID('type', {
description: 'The type of work.'
description: 'The type of work.',
}),
artists,
relationships,
tags
})
})
collections,
rating,
tags,
}),
});
export const WorkConnection = connectionWithExtras(Work)
export default Work
export const WorkConnection = connectionWithExtras(Work);
export const works = linkedQuery(WorkConnection);

View file

@ -1,45 +1,99 @@
import util from 'util'
import util from 'util';
import dashify from 'dashify';
import pascalCase from 'pascalcase';
export function getFields (info, fragments = info.fragments) {
export const ONE_DAY = 24 * 60 * 60 * 1000;
export function getFields(
info,
fragments = info.fragments,
depth = 0,
prefix = ''
) {
if (info.kind !== 'Field') {
info = info.fieldNodes[0]
info = info.fieldNodes[0];
}
const selections = info.selectionSet.selections
const selections = info.selectionSet.selections;
const reducer = (fields, selection) => {
if (selection.kind === 'FragmentSpread') {
const name = selection.name.value
const fragment = fragments[name]
const name = selection.name.value;
const fragment = fragments[name];
if (!fragment) {
throw new Error(`Fragment '${name}' was not passed to getFields()`)
throw new Error(`Fragment '${name}' was not passed to getFields()`);
}
fragment.selectionSet.selections.reduce(reducer, fields)
fragment.selectionSet.selections.reduce(reducer, fields);
} else if (selection.kind === 'InlineFragment') {
selection.selectionSet.selections.reduce(reducer, fields);
} else {
fields[selection.name.value] = selection
const prefixedName = prefix + selection.name.value;
fields[prefixedName] = selection;
if (depth > 0 && selection.selectionSet) {
const subFields = getFields(
selection,
fragments,
depth - 1,
`${prefixedName}.`
);
Object.assign(fields, subFields);
}
}
return fields
}
return selections.reduce(reducer, {})
return fields;
};
return selections.reduce(reducer, {});
}
export function prettyPrint (obj, { depth = 5,
colors = true,
breakLength = 120 } = {}) {
console.log(util.inspect(obj, { depth, colors, breakLength }))
export function prettyPrint(
obj,
{ depth = 5, colors = true, breakLength = 120 } = {}
) {
console.log(util.inspect(obj, { depth, colors, breakLength }));
}
export function toFilteredArray (obj) {
return (Array.isArray(obj) ? obj : [obj]).filter(x => x)
export function toFilteredArray(obj) {
return (Array.isArray(obj) ? obj : [obj]).filter((x) => x);
}
export function extendIncludes (includes, moreIncludes) {
includes = toFilteredArray(includes)
moreIncludes = toFilteredArray(moreIncludes)
const seen = {}
return includes.concat(moreIncludes).filter(x => {
export function extendIncludes(includes, moreIncludes) {
includes = toFilteredArray(includes);
moreIncludes = toFilteredArray(moreIncludes);
const seen = {};
return includes.concat(moreIncludes).filter((x) => {
if (seen[x]) {
return false
return false;
}
seen[x] = true
return true
})
seen[x] = true;
return true;
});
}
export const toPascal = pascalCase;
export const toDashed = dashify;
export function toPlural(name) {
return name.endsWith('s') ? name : name + 's';
}
export function toSingular(name) {
return name.endsWith('s') && !/series/i.test(name) ? name.slice(0, -1) : name;
}
export function toWords(name) {
return toPascal(name).replace(/([^A-Z])?([A-Z]+)/g, (match, tail, head) => {
tail = tail ? tail + ' ' : '';
head = head.length > 1 ? head : head.toLowerCase();
return `${tail}${head}`;
});
}
export function filterObjectValues(obj, filter) {
return Object.entries(obj).reduce((obj, [key, value]) => {
if (filter(value)) {
obj[key] = value;
}
return obj;
}, {});
}
export function getTypeName(value) {
return Object.prototype.toString.call(value).slice(8, -1);
}

1475
test/_schema.js Normal file

File diff suppressed because it is too large Load diff

14
test/api/client.js Normal file
View file

@ -0,0 +1,14 @@
import test from 'ava';
import Client from '../../src/api/client.js';
test('parseErrorMessage() returns the input error by default', (t) => {
const client = new Client();
const error = {
name: 'HTTPError',
response: {
statusCode: 500,
body: 'something went wrong',
},
};
t.is(client.parseErrorMessage(error), error);
});

View file

@ -0,0 +1,116 @@
[
[
"getBrowseURL() generates a browse URL",
[
]
],
[
"getLookupURL() generates a lookup URL",
[
]
],
[
"getSearchURL() generates a search URL",
[
]
],
[
"lookup() sends a lookup query",
[
{
"body": "",
"method": "GET",
"path": "/ws/2/artist/c8da2e40-bd28-4d4e-813a-bd2f51958ba8?fmt=json",
"rawHeaders": [
"Date",
"Thu, 15 Apr 2021 09:07:10 GMT",
"Content-Type",
"application/json; charset=utf-8",
"Transfer-Encoding",
"chunked",
"Connection",
"close",
"Vary",
"Accept-Encoding",
"X-RateLimit-Limit",
"1200",
"X-RateLimit-Remaining",
"783",
"X-RateLimit-Reset",
"1618477631",
"Server",
"Plack::Handler::Starlet",
"ETag",
"W/\"80f1f0e96231d62805a0ac7d54414c6f\"",
"Access-Control-Allow-Origin",
"*",
"X-Cache-Status",
"STALE"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "musicbrainz.org"
},
"response": "{\"life-span\":{\"end\":null,\"ended\":false,\"begin\":\"2013\"},\"id\":\"c8da2e40-bd28-4d4e-813a-bd2f51958ba8\",\"type-id\":\"e431f5f6-b5d2-343d-8b36-72607fffb74b\",\"begin_area\":{\"type-id\":null,\"id\":\"10adc6b5-63bf-4b4e-993e-ed83b05c22fc\",\"sort-name\":\"Seattle\",\"name\":\"Seattle\",\"type\":null,\"disambiguation\":\"\"},\"country\":null,\"gender-id\":null,\"disambiguation\":\"Seattle trio\",\"isnis\":[],\"gender\":null,\"sort-name\":\"Lures\",\"name\":\"Lures\",\"end-area\":null,\"begin-area\":{\"type-id\":null,\"id\":\"10adc6b5-63bf-4b4e-993e-ed83b05c22fc\",\"sort-name\":\"Seattle\",\"name\":\"Seattle\",\"type\":null,\"disambiguation\":\"\"},\"end_area\":null,\"ipis\":[],\"type\":\"Group\",\"area\":{\"disambiguation\":\"\",\"type\":null,\"type-id\":null,\"id\":\"10adc6b5-63bf-4b4e-993e-ed83b05c22fc\",\"name\":\"Seattle\",\"sort-name\":\"Seattle\"}}",
"responseIsBinary": false,
"scope": "http://musicbrainz.org:80",
"status": 200
}
]
],
[
"rejects non-MusicBrainz errors",
[
]
],
[
"rejects the promise when the API returns an error",
[
{
"body": "",
"method": "GET",
"path": "/ws/2/artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da?inc=foobar&fmt=json",
"rawHeaders": [
"Date",
"Thu, 15 Apr 2021 09:07:10 GMT",
"Content-Type",
"application/json; charset=utf-8",
"Content-Length",
"144",
"Connection",
"close",
"X-RateLimit-Limit",
"1200",
"X-RateLimit-Remaining",
"664",
"X-RateLimit-Reset",
"1618477631",
"Server",
"Plack::Handler::Starlet",
"ETag",
"\"294308a5f1930ea2b39414c0b8ec853c\"",
"Access-Control-Allow-Origin",
"*"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "musicbrainz.org"
},
"response": {
"error": "foobar is not a valid inc parameter for the artist resource.",
"help": "For usage, please see: https://musicbrainz.org/development/mmd"
},
"responseIsBinary": false,
"scope": "http://musicbrainz.org:80",
"status": 400
}
]
],
[
"uses the default error impementation if there is no JSON error",
[
]
]
]

87
test/api/musicbrainz.js Normal file
View file

@ -0,0 +1,87 @@
import test from 'ava';
import MusicBrainz from '../../src/api/index.js';
import client from '../helpers/client/musicbrainz.js';
test('getLookupURL() generates a lookup URL', (t) => {
t.is(
client.getLookupURL('artist', 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8', {
inc: ['recordings', 'release-groups'],
}),
'artist/c8da2e40-bd28-4d4e-813a-bd2f51958ba8?inc=recordings%2Brelease-groups'
);
});
test('getBrowseURL() generates a browse URL', (t) => {
t.is(
client.getBrowseURL('recording', {
artist: 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8',
limit: null,
offset: 0,
}),
'recording?artist=c8da2e40-bd28-4d4e-813a-bd2f51958ba8&offset=0'
);
});
test('getSearchURL() generates a search URL', (t) => {
t.is(
client.getSearchURL('artist', 'Lures', { inc: null }),
'artist?query=Lures'
);
});
test('lookup() sends a lookup query', async (t) => {
const response = await client.lookup(
'artist',
'c8da2e40-bd28-4d4e-813a-bd2f51958ba8'
);
t.is(response.id, 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8');
t.is(response.type, 'Group');
});
test('rejects the promise when the API returns an error', (t) => {
const req = client.lookup('artist', '5b11f4ce-a62d-471e-81fc-a69a8278c7da', {
inc: ['foobar'],
});
return t.throwsAsync(req, {
name: 'MusicBrainzError',
message: 'foobar is not a valid inc parameter for the artist resource.',
});
});
test('rejects non-MusicBrainz errors', (t) => {
const client = new MusicBrainz({ baseURL: '$!@#$' });
return t.throwsAsync(
client.get('artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da'),
{
name: 'TypeError',
}
);
});
test('uses the default error impementation if there is no JSON error', (t) => {
let error = {
name: 'HTTPError',
response: {
statusCode: 501,
body: 'yikes',
},
};
t.is(client.parseErrorMessage(error), error);
error = {
name: 'HTTPError',
response: {
statusCode: 500,
body: {},
},
};
t.is(client.parseErrorMessage(error), error);
error = {
name: 'HTTPError',
response: {
statusCode: 404,
body: null,
},
};
t.is(client.parseErrorMessage(error), error);
});

8
test/base-schema.js Normal file
View file

@ -0,0 +1,8 @@
import test from 'ava';
import { baseSchema } from '../src/schema.js';
test.before((t) => {
t.context.schema = baseSchema;
});
import './_schema.js';

13
test/extended-schema.js Normal file
View file

@ -0,0 +1,13 @@
import test from 'ava';
import { baseSchema, createSchema } from '../src/schema.js';
import { defaultExtensions, loadExtension } from '../src/index.js';
test.before(async (t) => {
const extensions = await Promise.all(
defaultExtensions.map((extension) => loadExtension(extension))
);
const schema = createSchema(baseSchema, { extensions });
t.context.schema = schema;
});
import './_schema.js';

View file

@ -0,0 +1,87 @@
import test from 'ava';
import client from '../../helpers/client/cover-art-archive.js';
test('can retrieve a front image URL', async (t) => {
const url = await client.imageURL(
'release',
'76df3287-6cda-33eb-8e9a-044b5e15ffdd',
'front'
);
t.is(
url,
'http://archive.org/download/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd-829521842.jpg'
);
});
test('can retrieve a back image URL', async (t) => {
const url = await client.imageURL(
'release',
'76df3287-6cda-33eb-8e9a-044b5e15ffdd',
'back'
);
t.is(
url,
'http://archive.org/download/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd-5769317885.jpg'
);
});
test('can retrieve a list of release images', async (t) => {
const data = await client.images(
'release',
'76df3287-6cda-33eb-8e9a-044b5e15ffdd'
);
t.is(
data.release,
'https://musicbrainz.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd'
);
t.true(data.images.length >= 3);
data.images.forEach((image) => {
t.true(image.approved);
t.truthy(image.image);
t.truthy(image.id);
t.truthy(image.thumbnails.small);
t.truthy(image.thumbnails.large);
});
t.true(data.images.some((image) => image.front));
t.true(data.images.some((image) => image.back));
t.true(data.images.some((image) => image.types.indexOf('Front') !== -1));
t.true(data.images.some((image) => image.types.indexOf('Back') !== -1));
t.true(data.images.some((image) => image.types.indexOf('Medium') !== -1));
});
test('throws an error if given an invalid MBID', (t) => {
return t.throwsAsync(client.images('release', 'xyz'), {
name: 'CoverArtArchiveError',
message: 'Bad Request: invalid MBID specified',
});
});
test('uses the default error impementation if there is no HTML error', (t) => {
let error = {
name: 'HTTPError',
response: {
statusCode: 501,
body: 'yikes',
},
};
t.is(client.parseErrorMessage(error), error);
error = {
name: 'HTTPError',
response: {
statusCode: 500,
body: '',
},
};
t.is(client.parseErrorMessage(error), error);
error = {
name: 'HTTPError',
response: {
statusCode: 500,
body: null,
},
};
t.is(client.parseErrorMessage(error), error);
});

View file

@ -0,0 +1,332 @@
[
[
"can retrieve a back image URL",
[
{
"body": "",
"method": "HEAD",
"path": "/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/back",
"rawHeaders": [
"Date",
"Thu, 15 Apr 2021 09:07:12 GMT",
"Content-Type",
"text/plain; charset=utf-8",
"Content-Length",
"132",
"Connection",
"close",
"Location",
"http://archive.org/download/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd-5769317885.jpg",
"Access-Control-Allow-Origin",
"*"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "coverartarchive.org"
},
"response": "",
"responseIsBinary": false,
"scope": "http://coverartarchive.org:80",
"status": 307
}
]
],
[
"can retrieve a front image URL",
[
{
"body": "",
"method": "HEAD",
"path": "/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/front",
"rawHeaders": [
"Date",
"Thu, 15 Apr 2021 09:07:12 GMT",
"Content-Type",
"text/plain; charset=utf-8",
"Content-Length",
"131",
"Connection",
"close",
"Location",
"http://archive.org/download/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd-829521842.jpg",
"Access-Control-Allow-Origin",
"*"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "coverartarchive.org"
},
"response": "",
"responseIsBinary": false,
"scope": "http://coverartarchive.org:80",
"status": 307
}
]
],
[
"can retrieve a list of release images",
[
{
"body": "",
"method": "GET",
"path": "/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd",
"rawHeaders": [
"Date",
"Thu, 15 Apr 2021 09:07:12 GMT",
"Content-Type",
"text/plain; charset=utf-8",
"Content-Length",
"86",
"Connection",
"close",
"Location",
"http://archive.org/download/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/index.json",
"Access-Control-Allow-Origin",
"*"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "coverartarchive.org"
},
"response": "See: http://archive.org/download/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/index.json\n",
"responseIsBinary": false,
"scope": "http://coverartarchive.org:80",
"status": 307
},
{
"body": "",
"method": "GET",
"path": "/download/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/index.json",
"rawHeaders": [
"Server",
"nginx/1.16.1 (Ubuntu)",
"Date",
"Thu, 15 Apr 2021 09:07:12 GMT",
"Content-Type",
"text/html; charset=UTF-8",
"Transfer-Encoding",
"chunked",
"Connection",
"close",
"Access-Control-Allow-Origin",
"*",
"Accept-Ranges",
"bytes",
"Location",
"http://ia802607.us.archive.org/32/items/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/index.json",
"Strict-Transport-Security",
"max-age=15724800",
"Referrer-Policy",
"no-referrer-when-downgrade"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "archive.org"
},
"response": "",
"responseIsBinary": false,
"scope": "http://archive.org:80",
"status": 302
},
{
"body": "",
"method": "GET",
"path": "/32/items/mbid-76df3287-6cda-33eb-8e9a-044b5e15ffdd/index.json",
"rawHeaders": [
"Server",
"nginx/1.18.0 (Ubuntu)",
"Date",
"Thu, 15 Apr 2021 09:07:13 GMT",
"Content-Type",
"application/json",
"Content-Length",
"4445",
"Last-Modified",
"Tue, 12 Nov 2019 22:11:45 GMT",
"Connection",
"close",
"ETag",
"\"5dcb2e21-115d\"",
"Strict-Transport-Security",
"max-age=15724800",
"Expires",
"Thu, 15 Apr 2021 15:07:13 GMT",
"Cache-Control",
"max-age=21600",
"Access-Control-Allow-Origin",
"*",
"Accept-Ranges",
"bytes"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "ia802607.us.archive.org"
},
"response": {
"images": [
{
"approved": true,
"back": false,
"comment": "",
"edit": 17462565,
"front": true,
"id": 829521842,
"image": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/829521842.jpg",
"thumbnails": {
"1200": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/829521842-1200.jpg",
"250": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/829521842-250.jpg",
"500": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/829521842-500.jpg",
"large": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/829521842-500.jpg",
"small": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/829521842-250.jpg"
},
"types": [
"Front"
]
},
{
"approved": true,
"back": false,
"comment": "",
"edit": 65037291,
"front": false,
"id": 24546038908,
"image": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546038908.jpg",
"thumbnails": {
"1200": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546038908-1200.jpg",
"250": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546038908-250.jpg",
"500": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546038908-500.jpg",
"large": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546038908-500.jpg",
"small": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546038908-250.jpg"
},
"types": [
"Front",
"Booklet"
]
},
{
"approved": true,
"back": false,
"comment": "",
"edit": 65037298,
"front": false,
"id": 24546039829,
"image": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546039829.jpg",
"thumbnails": {
"1200": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546039829-1200.jpg",
"250": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546039829-250.jpg",
"500": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546039829-500.jpg",
"large": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546039829-500.jpg",
"small": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546039829-250.jpg"
},
"types": [
"Booklet"
]
},
{
"approved": true,
"back": false,
"comment": "",
"edit": 65037300,
"front": false,
"id": 24546040945,
"image": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546040945.jpg",
"thumbnails": {
"1200": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546040945-1200.jpg",
"250": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546040945-250.jpg",
"500": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546040945-500.jpg",
"large": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546040945-500.jpg",
"small": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/24546040945-250.jpg"
},
"types": [
"Booklet"
]
},
{
"approved": true,
"back": true,
"comment": "",
"edit": 24923554,
"front": false,
"id": 5769317885,
"image": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769317885.jpg",
"thumbnails": {
"1200": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769317885-1200.jpg",
"250": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769317885-250.jpg",
"500": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769317885-500.jpg",
"large": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769317885-500.jpg",
"small": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769317885-250.jpg"
},
"types": [
"Back"
]
},
{
"approved": true,
"back": false,
"comment": "",
"edit": 24923552,
"front": false,
"id": 5769316809,
"image": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769316809.jpg",
"thumbnails": {
"1200": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769316809-1200.jpg",
"250": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769316809-250.jpg",
"500": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769316809-500.jpg",
"large": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769316809-500.jpg",
"small": "http://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd/5769316809-250.jpg"
},
"types": [
"Medium"
]
}
],
"release": "https://musicbrainz.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd"
},
"responseIsBinary": false,
"scope": "http://ia802607.us.archive.org:80",
"status": 200
}
]
],
[
"throws an error if given an invalid MBID",
[
{
"body": "",
"method": "GET",
"path": "/release/xyz",
"rawHeaders": [
"Date",
"Thu, 15 Apr 2021 09:07:13 GMT",
"Content-Type",
"text/html",
"Content-Length",
"138",
"Connection",
"close",
"Access-Control-Allow-Origin",
"*"
],
"reqheaders": {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"host": "coverartarchive.org"
},
"response": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 3.2 Final//EN\">\n<title>400 Bad Request</title>\n<h1>Bad Request</h1>\n<p>invalid MBID specified</p>\n",
"responseIsBinary": false,
"scope": "http://coverartarchive.org:80",
"status": 400
}
]
],
[
"uses the default error impementation if there is no HTML error",
[
]
]
]

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more