mirror of
https://github.com/BradNut/graphbrainz
synced 2025-09-08 17:40:32 +00:00
Compare commits
90 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94cf657f30 | ||
|
|
c020795b58 | ||
|
|
42f237d068 | ||
|
|
740a499377 | ||
|
|
f095cd4de7 | ||
|
|
24d53d4687 | ||
|
|
24ebfa1b51 | ||
|
|
cb2d2a1a3f | ||
|
|
a6693ed5a4 | ||
|
|
6d3d5a595b | ||
|
|
50d3268366 | ||
|
|
fc6f1337b6 | ||
|
|
01ebffad01 | ||
|
|
ba21977847 | ||
|
|
1499e07fa7 | ||
|
|
50c3223e6a | ||
|
|
d3bb6d2484 | ||
|
|
e696ffc70e | ||
|
|
3ed5ba12d7 | ||
|
|
505b679376 | ||
|
|
ff828699a0 | ||
|
|
d9c029ff71 | ||
|
|
98e80f1bd1 | ||
|
|
11afa8e4e3 | ||
|
|
80495a33f0 | ||
|
|
9f0172ba9d | ||
|
|
4768d2f359 | ||
|
|
f425082758 | ||
|
|
55176e6753 | ||
|
|
eec40c64a0 | ||
|
|
37e872b1e7 | ||
|
|
d8b8de2dce | ||
|
|
8aae9d5634 | ||
|
|
37447d1a41 | ||
|
|
6d22f0c5b6 | ||
|
|
9ddb4aab67 | ||
|
|
14487a747e | ||
|
|
2d0bd82c8b | ||
|
|
c3be2a2e98 | ||
|
|
2de2e60079 | ||
|
|
ccce751ccb | ||
|
|
f34ea2002f | ||
|
|
edeabeecac | ||
|
|
5c411ed79d | ||
|
|
8c0a9f44ef | ||
|
|
50888c9fb9 | ||
|
|
35f6cf63ea | ||
|
|
c21009b5c4 | ||
|
|
75e24c18bc | ||
|
|
e77143fbd7 | ||
|
|
62495c490d | ||
|
|
e4569607f4 | ||
|
|
7f49ccf117 | ||
|
|
d2f4d118fc | ||
|
|
03dc011934 | ||
|
|
46d16ebd13 | ||
|
|
be67d771ab | ||
|
|
a3fc3e97af | ||
|
|
dff11f76c8 | ||
|
|
bbe045a28f | ||
|
|
a7183cc15c | ||
|
|
51cc879363 | ||
|
|
086b7469e1 | ||
|
|
aa46d45419 | ||
|
|
898ec78a6f | ||
|
|
687ca43708 | ||
|
|
b5d0dcce91 | ||
|
|
bc2a5655d8 | ||
|
|
759310a2a6 | ||
|
|
698ba58492 | ||
|
|
35db26f8ce | ||
|
|
c9d9cb944b | ||
|
|
8447161f29 | ||
|
|
2353f9b6c2 | ||
|
|
1e535f203e | ||
|
|
8807d1e5b8 | ||
|
|
f6ca61233c | ||
|
|
fd86710fdb | ||
|
|
3d41c0e3f3 | ||
|
|
bd12fc8812 | ||
|
|
025636d7c3 | ||
|
|
f558eba626 | ||
|
|
fc53b15455 | ||
|
|
64c624c574 | ||
|
|
61efd5d6fa | ||
|
|
5e9604ffe6 | ||
|
|
406fede9ea | ||
|
|
cf0b52b1ea | ||
|
|
debd296b44 | ||
|
|
4753c3ffa9 |
548 changed files with 54257 additions and 31533 deletions
10
.babelrc
10
.babelrc
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"presets": ["es2015", "stage-2"],
|
|
||||||
"plugins": ["transform-runtime"],
|
|
||||||
"only": ["scripts/**", "src/**", "test/helpers/**"],
|
|
||||||
"env": {
|
|
||||||
"test": {
|
|
||||||
"plugins": ["istanbul"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4
.eslintignore
Normal file
4
.eslintignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/.nyc_output
|
||||||
|
/coverage
|
||||||
|
/lib
|
||||||
|
!.eslintrc.cjs
|
||||||
45
.eslintrc.cjs
Normal file
45
.eslintrc.cjs
Normal 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
1
.gitignore
vendored
|
|
@ -36,4 +36,5 @@ jspm_packages
|
||||||
# Optional REPL history
|
# Optional REPL history
|
||||||
.node_repl_history
|
.node_repl_history
|
||||||
|
|
||||||
|
.env
|
||||||
lib
|
lib
|
||||||
|
|
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/.nyc_output
|
||||||
|
/coverage
|
||||||
|
/lib
|
||||||
13
.travis.yml
13
.travis.yml
|
|
@ -1,8 +1,9 @@
|
||||||
language: node_js
|
language: node_js
|
||||||
|
|
||||||
node_js:
|
node_js:
|
||||||
- "4"
|
- '12'
|
||||||
- "6"
|
- '14'
|
||||||
|
- '15'
|
||||||
|
|
||||||
# Use container-based Travis infrastructure.
|
# Use container-based Travis infrastructure.
|
||||||
sudo: false
|
sudo: false
|
||||||
|
|
@ -10,10 +11,16 @@ sudo: false
|
||||||
branches:
|
branches:
|
||||||
only:
|
only:
|
||||||
- master
|
- 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:
|
script:
|
||||||
- yarn test
|
- yarn test
|
||||||
|
|
||||||
after_success:
|
after_success:
|
||||||
- $(yarn bin)/nyc report --reporter=text-lcov | $(yarn bin)/coveralls
|
- $(yarn bin)/c8 report --reporter=text-lcov | $(yarn bin)/coveralls
|
||||||
- bash <(curl -s https://codecov.io/bash)
|
- bash <(curl -s https://codecov.io/bash)
|
||||||
|
|
|
||||||
168
README.md
168
README.md
|
|
@ -1,23 +1,38 @@
|
||||||
# GraphBrainz
|
# GraphBrainz
|
||||||
|
|
||||||
[](https://travis-ci.org/exogen/graphbrainz)
|
[](https://travis-ci.org/exogen/graphbrainz)
|
||||||
[](https://codecov.io/gh/exogen/graphbrainz)
|
[](https://codecov.io/gh/exogen/graphbrainz)
|
||||||
[](https://www.npmjs.com/package/graphbrainz)
|
[](https://www.npmjs.com/package/graphbrainz)
|
||||||
[](https://github.com/exogen/graphbrainz/blob/master/LICENSE)
|
[](https://github.com/exogen/graphbrainz/blob/master/LICENSE)
|
||||||
|
|
||||||
A [GraphQL][] schema, [Express][] server, and middleware for querying the
|
A [GraphQL][] schema, [Express][] server, and middleware for querying the
|
||||||
[MusicBrainz][] API.
|
[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
|
```sh
|
||||||
npm install graphbrainz --save
|
npm install graphbrainz --save
|
||||||
```
|
```
|
||||||
|
|
||||||
**[Try out the live demo!][demo]** :bulb: Use the “Docs” sidebar, the
|
Install with Yarn:
|
||||||
[schema][], or the [types][] docs to help construct your query.
|
|
||||||
|
```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 -->
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
- [As a standalone server](#as-a-standalone-server)
|
- [As a standalone server](#as-a-standalone-server)
|
||||||
|
|
@ -29,6 +44,7 @@ npm install graphbrainz --save
|
||||||
- [Pagination](#pagination)
|
- [Pagination](#pagination)
|
||||||
- [Questions](#questions)
|
- [Questions](#questions)
|
||||||
- [Schema](#schema)
|
- [Schema](#schema)
|
||||||
|
- [Extending the schema](#extending-the-schema)
|
||||||
|
|
||||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
|
||||||
|
|
@ -39,8 +55,8 @@ middleware supplying a GraphQL endpoint.
|
||||||
|
|
||||||
### As a standalone server
|
### As a standalone server
|
||||||
|
|
||||||
Run the included `graphbrainz` executable to start the server. The server
|
Run the included `graphbrainz` executable to start the server. The server is
|
||||||
is configured using [environment variables](#environment-variables).
|
configured using [environment variables](#environment-variables).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ graphbrainz
|
$ graphbrainz
|
||||||
|
|
@ -65,7 +81,7 @@ an endpoint, or you just want more customization, use the middleware.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import graphbrainz from 'graphbrainz';
|
import { middleware as graphbrainz } from 'graphbrainz';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
|
@ -84,12 +100,12 @@ app.listen(3000);
|
||||||
|
|
||||||
The `graphbrainz` middleware function accepts the following options:
|
The `graphbrainz` middleware function accepts the following options:
|
||||||
|
|
||||||
* **`client`**: A custom API client instance to use. See the
|
- **`client`**: A custom API client instance to use. See the
|
||||||
[client submodule](src/api.js) for help with creating a custom instance. You
|
[client submodule](src/api/client.js) for help with creating a custom
|
||||||
probably only need to do this if you want to adjust the rate limit and retry
|
instance. You probably only need to do this if you want to adjust the rate
|
||||||
behavior.
|
limit and retry behavior.
|
||||||
* Any remaining options are passed along to the standard GraphQL middleware.
|
- Any remaining options are passed along to the standard GraphQL middleware. See
|
||||||
See the [express-graphql][] documentation for more information.
|
the [express-graphql][] documentation for more information.
|
||||||
|
|
||||||
### As a client
|
### As a client
|
||||||
|
|
||||||
|
|
@ -100,16 +116,14 @@ GraphBrainz resolvers expect, like so:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { graphql } from 'graphql';
|
import { graphql } from 'graphql';
|
||||||
import { MusicBrainz, CoverArtArchive } from 'graphbrainz/api';
|
import { MusicBrainz, createContext, baseSchema } from 'graphbrainz';
|
||||||
import createLoaders from 'graphbrainz/loaders';
|
|
||||||
import schema from 'graphbrainz/schema';
|
|
||||||
|
|
||||||
const client = new MusicBrainz();
|
const client = new MusicBrainz();
|
||||||
const coverArtClient = new CoverArtArchive();
|
const context = createContext({ client });
|
||||||
const loaders = createLoaders(client, coverArtClient);
|
|
||||||
const context = { client, coverArtClient, loaders };
|
|
||||||
|
|
||||||
graphql(schema, `
|
graphql(
|
||||||
|
schema,
|
||||||
|
`
|
||||||
{
|
{
|
||||||
lookup {
|
lookup {
|
||||||
releaseGroup(mbid: "99599db8-0e36-4a93-b0e8-350e9d7502a9") {
|
releaseGroup(mbid: "99599db8-0e36-4a93-b0e8-350e9d7502a9") {
|
||||||
|
|
@ -117,31 +131,43 @@ graphql(schema, `
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`, null, context).then(result => {
|
`,
|
||||||
|
null,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
.then((result) => {
|
||||||
const { releaseGroup } = result.data.lookup;
|
const { releaseGroup } = result.data.lookup;
|
||||||
console.log(`The album title is “${releaseGroup.title}”.`);
|
console.log(`The album title is “${releaseGroup.title}”.`);
|
||||||
}).catch(err => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
* **`MUSICBRAINZ_BASE_URL`**: The base MusicBrainz API URL to use. Change this
|
- **`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/`.
|
if you are running your own MusicBrainz mirror. Defaults to
|
||||||
* **`GRAPHBRAINZ_PATH`**: The URL route at which to expose the GraphQL endpoint,
|
`http://musicbrainz.org/ws/2/`.
|
||||||
|
- **`GRAPHBRAINZ_PATH`**: The URL route at which to expose the GraphQL endpoint,
|
||||||
if running the standalone server. Defaults to `/`.
|
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
|
cache. Increasing the cache size and TTL will greatly lower query execution
|
||||||
time for complex queries involving frequently accessed entities. Defaults to
|
time for complex queries involving frequently accessed entities. Defaults to
|
||||||
`8192`.
|
`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
|
cache, in milliseconds. Responses older than this will be disposed of (and
|
||||||
re-requested) the next time they are accessed. Defaults to `86400000` (one
|
re-requested) the next time they are accessed. Defaults to `86400000` (one
|
||||||
day).
|
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.
|
[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
|
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
|
from a `.env` file, if one exists in the current working directory. This just
|
||||||
|
|
@ -161,7 +187,8 @@ See the [debug][] package for more information.
|
||||||
|
|
||||||
## Example Queries
|
## Example Queries
|
||||||
|
|
||||||
Nirvana albums and each album’s 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 album’s 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
|
```graphql
|
||||||
query NirvanaAlbumSingles {
|
query NirvanaAlbumSingles {
|
||||||
|
|
@ -197,7 +224,8 @@ query NirvanaAlbumSingles {
|
||||||
|
|
||||||
### Pagination
|
### 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
|
```graphql
|
||||||
query AppleLabels {
|
query AppleLabels {
|
||||||
|
|
@ -226,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
|
```graphql
|
||||||
query AppleLabels {
|
query AppleLabels {
|
||||||
|
|
@ -239,7 +268,7 @@ query AppleLabels {
|
||||||
```
|
```
|
||||||
|
|
||||||
Who the members of the band on an Apple Records release married, and when
|
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
|
```graphql
|
||||||
query AppleRecordsMarriages {
|
query AppleRecordsMarriages {
|
||||||
|
|
@ -311,21 +340,54 @@ 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][].
|
You can find more example queries in the [schema tests][].
|
||||||
|
|
||||||
## Questions
|
## Questions
|
||||||
|
|
||||||
**What’s with the cumbersome `edges`/`node` nesting? Why `first`/`after`
|
**What’s with the cumbersome `edges`/`node` nesting? Why `first`/`after` instead
|
||||||
instead of `limit`/`offset`? Why `mbid` instead of `id`?**
|
of `limit`/`offset`? Why `mbid` instead of `id`?**
|
||||||
|
|
||||||
You can thank [Relay][] for that; these are properties of a Relay-compliant
|
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
|
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
|
end I decided that being compatible with Relay was a worthwhile feature. I
|
||||||
agree, it’s ugly.
|
agree, it’s ugly.
|
||||||
|
|
||||||
Don’t forget, though, that you can use [GraphQL aliases][aliases] to rename
|
The GraphBrainz schema includes an extra `nodes` field on every connection type.
|
||||||
fields to your liking. For example, the following query renames `edges`, `node`,
|
If you only want the nodes and no other fields on `edges`, you can use `nodes`
|
||||||
and `mbid` to `results`, `releaseGroup`, and `id`, respectively:
|
as a shortcut.
|
||||||
|
|
||||||
|
Don’t 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
|
```graphql
|
||||||
query ChristmasAlbums {
|
query ChristmasAlbums {
|
||||||
|
|
@ -346,9 +408,9 @@ query ChristmasAlbums {
|
||||||
|
|
||||||
It’s likely that your query requires multiple round trips to the MusicBrainz
|
It’s likely that your query requires multiple round trips to the MusicBrainz
|
||||||
REST API, which is subject to [rate limiting][]. While the query resolver tries
|
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
|
very hard to fetch only the data necessary, and with the smallest number of API
|
||||||
API requests, it is not 100% optimal (yet). Make sure you are only requesting
|
requests, it is not 100% optimal (yet). Make sure you are only requesting the
|
||||||
the fields you need and a reasonable level of nested entities – unless you are
|
fields you need and a reasonable level of nested entities – unless you are
|
||||||
willing to wait.
|
willing to wait.
|
||||||
|
|
||||||
You can also set up a [local MusicBrainz mirror][mirror] and configure
|
You can also set up a [local MusicBrainz mirror][mirror] and configure
|
||||||
|
|
@ -356,21 +418,29 @@ GraphBrainz to use that with no rate limiting.
|
||||||
|
|
||||||
## Schema
|
## 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/
|
[demo]: https://graphbrainz.herokuapp.com/
|
||||||
[Express]: http://expressjs.com/
|
[express]: http://expressjs.com/
|
||||||
[MusicBrainz]: https://musicbrainz.org/
|
[musicbrainz]: https://musicbrainz.org/
|
||||||
[GraphQL]: http://graphql.org/
|
[graphql]: http://graphql.org/
|
||||||
[express-graphql]: https://www.npmjs.com/package/express-graphql
|
[express-graphql]: https://www.npmjs.com/package/express-graphql
|
||||||
[dotenv]: https://www.npmjs.com/package/dotenv
|
[dotenv]: https://www.npmjs.com/package/dotenv
|
||||||
[debug]: https://www.npmjs.com/package/debug
|
[debug]: https://www.npmjs.com/package/debug
|
||||||
[GraphiQL]: https://github.com/graphql/graphiql
|
[graphiql]: https://github.com/graphql/graphiql
|
||||||
[graphql-js]: https://github.com/graphql/graphql-js
|
[graphql-js]: https://github.com/graphql/graphql-js
|
||||||
[Relay]: https://facebook.github.io/relay/
|
[relay]: https://facebook.github.io/relay/
|
||||||
[schema]: docs/schema.md
|
[schema]: docs/schema.md
|
||||||
[types]: docs/types.md
|
[types]: docs/types.md
|
||||||
[rate limiting]: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
|
[rate limiting]: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
|
||||||
[mirror]: https://musicbrainz.org/doc/MusicBrainz_Server/Setup
|
[mirror]: https://musicbrainz.org/doc/MusicBrainz_Server/Setup
|
||||||
[aliases]: http://graphql.org/learn/queries/#aliases
|
[aliases]: http://graphql.org/learn/queries/#aliases
|
||||||
[schema tests]: test/schema.js
|
[schema tests]: test/_schema.js
|
||||||
|
[cors]: https://github.com/expressjs/cors
|
||||||
|
|
|
||||||
4
cli.js
Executable file
4
cli.js
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { start } from './src/index.js';
|
||||||
|
|
||||||
|
start();
|
||||||
255
docs/extensions/README.md
Normal file
255
docs/extensions/README.md
Normal 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 you’d 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 doesn’t 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 aren’t 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 extension’s options.
|
||||||
|
- Don’t 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 they’re 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 it’s 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`.
|
||||||
|
- Don’t include fields that are already available in MusicBrainz (unless it’s
|
||||||
|
possible to retrieve an entity that isn’t in MusicBrainz). Only include
|
||||||
|
what’s relevant and useful.
|
||||||
|
- Add descriptions for everything: types, fields, arguments, enum values, etc.
|
||||||
|
– with Markdown links wherever they’d 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 it’s more
|
||||||
|
obvious that the data source isn’t MusicBrainz itself, and you’re 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
|
||||||
382
docs/extensions/cover-art-archive.md
Normal file
382
docs/extensions/cover-art-archive.md
Normal 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 Archive’s 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 release’s
|
||||||
|
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 image’s original dimensions, with no maximum.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- END graphql-markdown -->
|
||||||
479
docs/extensions/fanart-tv.md
Normal file
479
docs/extensions/fanart-tv.md
Normal 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 artist’s 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 artist’s 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 label’s 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 image’s 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 -->
|
||||||
411
docs/extensions/mediawiki.md
Normal file
411
docs/extensions/mediawiki.md
Normal 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 artist’s 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 instrument’s 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 label’s 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 place’s 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 -->
|
||||||
776
docs/extensions/the-audio-db.md
Normal file
776
docs/extensions/the-audio-db.md
Normal 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 album’s 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 album’s 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 artist’s 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 track’s 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 album’s 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 image’s 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 -->
|
||||||
4365
docs/schema.md
4365
docs/schema.md
File diff suppressed because it is too large
Load diff
2880
docs/types.md
2880
docs/types.md
File diff suppressed because it is too large
Load diff
1
extensions/cover-art-archive.js
Normal file
1
extensions/cover-art-archive.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from '../src/extensions/cover-art-archive/index.js';
|
||||||
1
extensions/fanart-tv.js
Normal file
1
extensions/fanart-tv.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from '../src/extensions/fanart-tv/index.js';
|
||||||
1
extensions/mediawiki.js
Normal file
1
extensions/mediawiki.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from '../src/extensions/mediawiki/index.js';
|
||||||
1
extensions/the-audio-db.js
Normal file
1
extensions/the-audio-db.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from '../src/extensions/the-audio-db/index.js';
|
||||||
187
package.json
187
package.json
|
|
@ -1,47 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "graphbrainz",
|
"name": "graphbrainz",
|
||||||
"version": "5.1.0",
|
"version": "9.0.0",
|
||||||
"description": "An Express server and middleware for querying the MusicBrainz API using GraphQL.",
|
"description": "A GraphQL schema, Express server, and middleware for querying the MusicBrainz.",
|
||||||
"main": "lib/index.js",
|
|
||||||
"bin": "lib/index.js",
|
|
||||||
"files": [
|
|
||||||
"lib",
|
|
||||||
"scripts",
|
|
||||||
"Procfile",
|
|
||||||
"schema.json",
|
|
||||||
"yarn.lock"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^4.3.0, ^6.2.0",
|
|
||||||
"npm": "^3.8.0, ^4.0.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": "graphql-markdown --prologue 'You may also be interested in reading the [schema in GraphQL syntax](schema.md).' ./schema.json > docs/types.md",
|
|
||||||
"build:lib": "babel --out-dir lib src",
|
|
||||||
"clean": "npm run clean:lib",
|
|
||||||
"clean:lib": "rm -rf lib",
|
|
||||||
"deploy": "./scripts/deploy.sh",
|
|
||||||
"lint": "standard --verbose | snazzy",
|
|
||||||
"lint:fix": "standard --verbose --fix",
|
|
||||||
"prepublish": "npm run clean:lib && npm run build:lib",
|
|
||||||
"preversion": "npm run update-schema && npm run build:docs && git add schema.json docs",
|
|
||||||
"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": "npm run lint && npm run test:coverage",
|
|
||||||
"test:coverage": "cross-env NODE_ENV=test nyc npm run test:only",
|
|
||||||
"test:only": "cross-env VCR_MODE=playback ava",
|
|
||||||
"test:record": "cross-env VCR_MODE=record ava",
|
|
||||||
"test:record-new": "cross-env VCR_MODE=cache ava --serial",
|
|
||||||
"test:watch": "npm run test:only -- --watch",
|
|
||||||
"update-schema": "npm run -s print-schema:json > schema.json"
|
|
||||||
},
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"musicbrainz",
|
"musicbrainz",
|
||||||
"graphql",
|
"graphql",
|
||||||
|
|
@ -54,72 +14,107 @@
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Brian Beck",
|
"name": "Brian Beck",
|
||||||
"email": "exogen@gmail.com",
|
"email": "exogen@gmail.com",
|
||||||
"url": "http://brianbeck.com/"
|
"url": "https://brianbeck.com/"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/exogen/graphbrainz.git"
|
"url": "https://github.com/exogen/graphbrainz.git"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"babel-runtime": "^6.23.0",
|
"node": ">=12.18.0",
|
||||||
"compression": "^1.6.2",
|
"npm": ">=6.0.0"
|
||||||
"dashify": "^0.2.2",
|
|
||||||
"dataloader": "^1.3.0",
|
|
||||||
"debug": "^2.6.3",
|
|
||||||
"dotenv": "^4.0.0",
|
|
||||||
"es6-error": "^4.0.2",
|
|
||||||
"express": "^4.15.2",
|
|
||||||
"express-graphql": "^0.6.3",
|
|
||||||
"graphql": "^0.9.1",
|
|
||||||
"graphql-relay": "^0.5.1",
|
|
||||||
"lru-cache": "^4.0.1",
|
|
||||||
"pascalcase": "^0.1.1",
|
|
||||||
"qs": "^6.4.0",
|
|
||||||
"request": "^2.81.0",
|
|
||||||
"retry": "^0.10.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"type": "module",
|
||||||
"ava": "^0.18.2",
|
"main": "./src/index.js",
|
||||||
"babel-cli": "^6.24.0",
|
"exports": {
|
||||||
"babel-eslint": "^7.1.1",
|
".": "./src/index.js",
|
||||||
"babel-plugin-istanbul": "^4.0.0",
|
"./extensions/cover-art-archive": "./extensions/cover-art-archive.js",
|
||||||
"babel-plugin-transform-runtime": "^6.23.0",
|
"./extensions/fanart-tv": "./extensions/fanart-tv.js",
|
||||||
"babel-preset-es2015": "^6.24.0",
|
"./extensions/mediawiki": "./extensions/mediawiki.js",
|
||||||
"babel-preset-stage-2": "^6.22.0",
|
"./extensions/the-audio-db": "./extensions/the-audio-db.js",
|
||||||
"babel-register": "^6.24.0",
|
"./package.json": "./package.json",
|
||||||
"coveralls": "^2.12.0",
|
"./schema.json": "./schema.json"
|
||||||
"cross-env": "^3.2.4",
|
|
||||||
"doctoc": "^1.3.0",
|
|
||||||
"graphql-markdown": "^1.0.2",
|
|
||||||
"marked": "^0.3.6",
|
|
||||||
"nodemon": "^1.11.0",
|
|
||||||
"nyc": "^10.1.2",
|
|
||||||
"rimraf": "^2.6.1",
|
|
||||||
"sepia": "^2.0.2",
|
|
||||||
"sinon": "^2.0.0",
|
|
||||||
"snazzy": "^6.0.0",
|
|
||||||
"standard": "^9.0.2"
|
|
||||||
},
|
},
|
||||||
"standard": {
|
"bin": "cli.js",
|
||||||
"parser": "babel-eslint"
|
"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": {
|
"ava": {
|
||||||
"require": [
|
"require": [
|
||||||
"babel-register"
|
"dotenv/config"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nyc": {
|
"ava-nock": {
|
||||||
"include": [
|
"fixtureDir": "fixtures",
|
||||||
"src/**"
|
"pathFilter": [
|
||||||
],
|
"(([?&]api_key=)(\\w+))|((/json/)(\\w+)(/[\\w-]+-mb\\.php))",
|
||||||
"reporter": [
|
"$2$5*$7"
|
||||||
"lcov",
|
]
|
||||||
"text"
|
},
|
||||||
],
|
"dependencies": {
|
||||||
"all": true,
|
"@graphql-tools/schema": "^7.1.3",
|
||||||
"cache": true,
|
"compression": "^1.7.3",
|
||||||
"sourceMap": false,
|
"cors": "^2.8.4",
|
||||||
"instrument": false
|
"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": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1312
schema.json
1312
schema.json
File diff suppressed because it is too large
Load diff
62
scripts/build-extension-docs.js
Normal file
62
scripts/build-extension-docs.js
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,7 @@ RESET='\033[0m'
|
||||||
# Fail if the `heroku` remote isn't there.
|
# Fail if the `heroku` remote isn't there.
|
||||||
git remote show heroku
|
git remote show heroku
|
||||||
|
|
||||||
git stash # Stash uncommitted changes.
|
STASH_OUTPUT=$(git stash) # Stash uncommitted changes.
|
||||||
git checkout -B deploy # Force branch creation/reset.
|
git checkout -B deploy # Force branch creation/reset.
|
||||||
npm run build
|
npm run build
|
||||||
git add -f lib # Force add ignored files.
|
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 rm -r --cached lib # Otherwise switching branches will remove them.
|
||||||
git checkout - # Switch back to whatever branch we came from.
|
git checkout - # Switch back to whatever branch we came from.
|
||||||
git branch -D deploy # Just to prevent someone accidentally pushing to GitHub.
|
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}"
|
echo -e "\n${GREEN}✔︎ Successfully deployed.${RESET}"
|
||||||
|
heroku open || true
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { graphql, introspectionQuery, printSchema } from 'graphql'
|
import GraphQL from 'graphql';
|
||||||
import schema from '../src/schema'
|
import { baseSchema as schema } from '../src/schema.js';
|
||||||
|
|
||||||
|
const { graphql, getIntrospectionQuery, printSchema } = GraphQL;
|
||||||
|
|
||||||
if (process.argv[2] === '--json') {
|
if (process.argv[2] === '--json') {
|
||||||
graphql(schema, introspectionQuery).then(result => {
|
graphql(schema, getIntrospectionQuery())
|
||||||
console.log(JSON.stringify(result.data, null, 2))
|
.then((result) => {
|
||||||
}).catch(err => {
|
console.log(JSON.stringify(result, null, 2));
|
||||||
console.error(err)
|
|
||||||
})
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(printSchema(schema))
|
console.log(printSchema(schema));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,147 +1,88 @@
|
||||||
import request from 'request'
|
import { fileURLToPath } from 'url';
|
||||||
import retry from 'retry'
|
import createDebug from 'debug';
|
||||||
import ExtendableError from 'es6-error'
|
import got from 'got';
|
||||||
import RateLimit from '../rate-limit'
|
import { readPackageUpSync } from 'read-pkg-up';
|
||||||
import pkg from '../../package.json'
|
import RateLimit from '../rate-limit.js';
|
||||||
|
import { filterObjectValues, getTypeName } from '../util.js';
|
||||||
|
|
||||||
const debug = require('debug')('graphbrainz:api/client')
|
const debug = createDebug('graphbrainz:api/client');
|
||||||
|
|
||||||
// If the `request` callback returns an error, it indicates a failure at a lower
|
const { packageJson: pkg } = readPackageUpSync({
|
||||||
// level than the HTTP response itself. If it's any of the following error
|
cwd: fileURLToPath(import.meta.url),
|
||||||
// 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 ClientError extends ExtendableError {
|
|
||||||
constructor (message, statusCode) {
|
|
||||||
super(message)
|
|
||||||
this.statusCode = statusCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Client {
|
export default class Client {
|
||||||
constructor({
|
constructor({
|
||||||
baseURL = process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/',
|
baseURL,
|
||||||
userAgent = `${pkg.name}/${pkg.version} ` +
|
userAgent = `${pkg.name}/${pkg.version} ` +
|
||||||
`( ${pkg.homepage || pkg.author.url || pkg.author.email} )`,
|
`( ${pkg.homepage || pkg.author.url || pkg.author.email} )`,
|
||||||
extraHeaders = {},
|
extraHeaders = {},
|
||||||
errorClass = ClientError,
|
|
||||||
timeout = 60000,
|
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 = 1,
|
limit = 1,
|
||||||
period = 1000,
|
period = 1000,
|
||||||
concurrency = 10,
|
concurrency = 10,
|
||||||
retries = 10,
|
retry,
|
||||||
// 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.baseURL = baseURL;
|
||||||
this.userAgent = userAgent
|
this.userAgent = userAgent;
|
||||||
this.extraHeaders = extraHeaders
|
this.extraHeaders = extraHeaders;
|
||||||
this.errorClass = errorClass
|
this.timeout = timeout;
|
||||||
this.timeout = timeout
|
this.limiter = new RateLimit({ limit, period, concurrency });
|
||||||
this.limiter = new RateLimit({ limit, period, concurrency })
|
this.retryOptions = retry;
|
||||||
this.retryOptions = {
|
|
||||||
retries,
|
|
||||||
minTimeout: retryDelayMin,
|
|
||||||
maxTimeout: retryDelayMax,
|
|
||||||
randomize: randomizeRetry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parseErrorMessage(err) {
|
||||||
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if we should retry the request based on the given error.
|
* Send a request without any rate limiting.
|
||||||
* Retry any 5XX response from MusicBrainz, as well as any error in
|
|
||||||
* `RETRY_CODES`.
|
|
||||||
*/
|
|
||||||
shouldRetry (err) {
|
|
||||||
if (err instanceof this.errorClass) {
|
|
||||||
return err.statusCode >= 500 && err.statusCode < 600
|
|
||||||
}
|
|
||||||
return RETRY_CODES[err.code] || false
|
|
||||||
}
|
|
||||||
|
|
||||||
parseErrorMessage (response, body) {
|
|
||||||
return typeof body === 'string' && body ? body : `${response.statusCode}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a request without any retrying or rate limiting.
|
|
||||||
* Use `get` instead.
|
* Use `get` instead.
|
||||||
*/
|
*/
|
||||||
_get (path, options = {}, info = {}) {
|
async _get(path, { searchParams, ...options } = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
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 = {
|
options = {
|
||||||
baseUrl: this.baseURL,
|
responseType: 'json',
|
||||||
url: path,
|
|
||||||
gzip: true,
|
|
||||||
timeout: this.timeout,
|
timeout: this.timeout,
|
||||||
|
retry: this.retryOptions,
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': this.userAgent,
|
'User-Agent': this.userAgent,
|
||||||
...this.extraHeaders,
|
...this.extraHeaders,
|
||||||
...options.headers
|
...options.headers,
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
debug(`Sending request. url=${this.baseURL}${path} attempt=${info.currentAttempt}`)
|
let response;
|
||||||
|
try {
|
||||||
request(options, (err, response, body) => {
|
debug(`Sending request. url=%s`, url);
|
||||||
if (err) {
|
response = await got(url.toString(), options);
|
||||||
debug(`Error: “${err}” url=${this.baseURL}${path}`)
|
debug(`Success: %s url=%s`, response.statusCode, url);
|
||||||
reject(err)
|
return response;
|
||||||
} else if (response.statusCode >= 400) {
|
} catch (err) {
|
||||||
const message = this.parseErrorMessage(response, body)
|
const parsedError = this.parseErrorMessage(err) || err;
|
||||||
debug(`Error: “${message}” url=${this.baseURL}${path}`)
|
debug(`Error: “%s” url=%s`, parsedError, url);
|
||||||
const ClientError = this.errorClass
|
throw parsedError;
|
||||||
reject(new ClientError(message, response.statusCode))
|
|
||||||
} else if (options.method === 'HEAD') {
|
|
||||||
resolve(response.headers)
|
|
||||||
} else {
|
|
||||||
resolve(body)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a request with retrying and rate limiting.
|
* Send a request with rate limiting.
|
||||||
*/
|
*/
|
||||||
get(path, options = {}) {
|
get(path, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
const fn = this._get.bind(this);
|
||||||
const fn = this._get.bind(this)
|
return this.limiter.enqueue(fn, [path, options]);
|
||||||
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, options, { currentAttempt }], priority)
|
|
||||||
.then(resolve)
|
|
||||||
.catch(err => {
|
|
||||||
if (!this.shouldRetry(err) || !operation.retry(err)) {
|
|
||||||
reject(operation.mainError() || err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import Client, { ClientError } from './client'
|
|
||||||
|
|
||||||
export class CoverArtArchiveError extends ClientError {}
|
|
||||||
|
|
||||||
export default class CoverArtArchive extends Client {
|
|
||||||
constructor ({
|
|
||||||
baseURL = process.env.COVER_ART_ARCHIVE_BASE_URL || 'http://coverartarchive.org/',
|
|
||||||
errorClass = CoverArtArchiveError,
|
|
||||||
limit = 10,
|
|
||||||
period = 1000,
|
|
||||||
...options
|
|
||||||
} = {}) {
|
|
||||||
super({ baseURL, errorClass, limit, period, ...options })
|
|
||||||
}
|
|
||||||
|
|
||||||
parseErrorMessage (response, body) {
|
|
||||||
if (typeof body === 'string' && body.startsWith('<!')) {
|
|
||||||
const heading = /<h1>([^<]+)<\/h1>/i.exec(body)
|
|
||||||
const message = /<p>([^<]+)<\/p>/i.exec(body)
|
|
||||||
return `${heading ? heading[1] + ': ' : ''}${message ? message[1] : ''}`
|
|
||||||
}
|
|
||||||
return super.parseErrorMessage(response, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
getImagesURL (entity, mbid) {
|
|
||||||
return `${entity}/${mbid}`
|
|
||||||
}
|
|
||||||
|
|
||||||
images (entity, mbid) {
|
|
||||||
const url = this.getImagesURL(entity, mbid)
|
|
||||||
return this.get(url, { json: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
getImageURL (entity, mbid, typeOrID = 'front', size) {
|
|
||||||
let url = `${entity}/${mbid}/${typeOrID}`
|
|
||||||
if (size != null) {
|
|
||||||
url += `-${size}`
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
imageURL (entity, mbid, typeOrID = 'front', size) {
|
|
||||||
const url = this.getImageURL(entity, mbid, typeOrID, size)
|
|
||||||
return this.get(url, { method: 'HEAD', followRedirect: false })
|
|
||||||
.then(headers => headers.location)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
import MusicBrainz, { MusicBrainzError } from './musicbrainz'
|
import MusicBrainz from './musicbrainz.js';
|
||||||
import CoverArtArchive, { CoverArtArchiveError } from './cover-art-archive'
|
|
||||||
|
|
||||||
export {
|
export { MusicBrainz as default, MusicBrainz };
|
||||||
MusicBrainz as default,
|
|
||||||
MusicBrainz,
|
|
||||||
MusicBrainzError,
|
|
||||||
CoverArtArchive,
|
|
||||||
CoverArtArchiveError
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,109 @@
|
||||||
import qs from 'qs'
|
import ExtendableError from 'es6-error';
|
||||||
import Client, { ClientError } from './client'
|
import Client from './client.js';
|
||||||
|
import { filterObjectValues } from '../util.js';
|
||||||
|
|
||||||
export class MusicBrainzError extends ClientError {}
|
export class MusicBrainzError extends ExtendableError {
|
||||||
|
constructor(message, response) {
|
||||||
|
super(message);
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class MusicBrainz extends Client {
|
export default class MusicBrainz extends Client {
|
||||||
constructor({
|
constructor({
|
||||||
baseURL = process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/',
|
baseURL = process.env.MUSICBRAINZ_BASE_URL ||
|
||||||
errorClass = MusicBrainzError,
|
'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,
|
limit = 5,
|
||||||
period = 5500,
|
period = 5500,
|
||||||
...options
|
...options
|
||||||
} = {}) {
|
} = {}) {
|
||||||
super({ baseURL, errorClass, limit, period, ...options })
|
super({ baseURL, limit, period, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
parseErrorMessage (response, body) {
|
parseErrorMessage(err) {
|
||||||
|
if (err.name === 'HTTPError') {
|
||||||
|
const { body } = err.response;
|
||||||
if (body && body.error) {
|
if (body && body.error) {
|
||||||
return body.error
|
return new MusicBrainzError(`${body.error}`, err.response);
|
||||||
}
|
}
|
||||||
return super.parseErrorMessage(response, body)
|
}
|
||||||
|
return super.parseErrorMessage(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(url, options = {}) {
|
||||||
|
options = {
|
||||||
|
resolveBodyOnly: true,
|
||||||
|
...options,
|
||||||
|
searchParams: {
|
||||||
|
fmt: 'json',
|
||||||
|
...options.searchParams,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return super.get(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
stringifyParams(params) {
|
stringifyParams(params) {
|
||||||
if (Array.isArray(params.inc)) {
|
if (Array.isArray(params.inc)) {
|
||||||
params = {
|
params = {
|
||||||
...params,
|
...params,
|
||||||
inc: params.inc.join('+')
|
inc: params.inc.join('+'),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
if (Array.isArray(params.type)) {
|
if (Array.isArray(params.type)) {
|
||||||
params = {
|
params = {
|
||||||
...params,
|
...params,
|
||||||
type: params.type.join('|')
|
type: params.type.join('|'),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
if (Array.isArray(params.status)) {
|
if (Array.isArray(params.status)) {
|
||||||
params = {
|
params = {
|
||||||
...params,
|
...params,
|
||||||
status: params.status.join('|')
|
status: params.status.join('|'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
return new URLSearchParams(
|
||||||
return qs.stringify(params, {
|
filterObjectValues(params, (value) => value != null && value !== '')
|
||||||
skipNulls: true,
|
).toString();
|
||||||
filter: (key, value) => value === '' ? undefined : value
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getURL(path, params) {
|
getURL(path, params) {
|
||||||
const query = params ? this.stringifyParams(params) : ''
|
const query = params ? this.stringifyParams(params) : '';
|
||||||
return query ? `${path}?${query}` : path
|
return query ? `${path}?${query}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLookupURL(entity, id, params) {
|
getLookupURL(entity, id, params) {
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
return this.getBrowseURL(entity, params)
|
return this.getBrowseURL(entity, params);
|
||||||
}
|
}
|
||||||
return this.getURL(`${entity}/${id}`, params)
|
return this.getURL(`${entity}/${id}`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
lookup(entity, id, params = {}) {
|
lookup(entity, id, params = {}) {
|
||||||
const url = this.getLookupURL(entity, id, params)
|
const url = this.getLookupURL(entity, id, params);
|
||||||
return this.get(url, { json: true, qs: { fmt: 'json' } })
|
return this.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBrowseURL(entity, params) {
|
getBrowseURL(entity, params) {
|
||||||
return this.getURL(entity, params)
|
return this.getURL(entity, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
browse(entity, params = {}) {
|
browse(entity, params = {}) {
|
||||||
const url = this.getBrowseURL(entity, params)
|
const url = this.getBrowseURL(entity, params);
|
||||||
return this.get(url, { json: true, qs: { fmt: 'json' } })
|
return this.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSearchURL(entity, query, params) {
|
getSearchURL(entity, query, params) {
|
||||||
return this.getURL(entity, { ...params, query })
|
return this.getURL(entity, { ...params, query });
|
||||||
}
|
}
|
||||||
|
|
||||||
search(entity, query, params = {}) {
|
search(entity, query, params = {}) {
|
||||||
const url = this.getSearchURL(entity, query, params)
|
const url = this.getSearchURL(entity, query, params);
|
||||||
return this.get(url, { json: true, qs: { fmt: 'json' } })
|
return this.get(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
src/context.js
Normal file
30
src/context.js
Normal 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);
|
||||||
|
}
|
||||||
55
src/extensions/cover-art-archive/client.js
Normal file
55
src/extensions/cover-art-archive/client.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/extensions/cover-art-archive/index.js
Normal file
40
src/extensions/cover-art-archive/index.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
68
src/extensions/cover-art-archive/loaders.js
Normal file
68
src/extensions/cover-art-archive/loaders.js
Normal 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,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/extensions/cover-art-archive/resolvers.js
Normal file
75
src/extensions/cover-art-archive/resolvers.js
Normal 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]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
176
src/extensions/cover-art-archive/schema.js
Normal file
176
src/extensions/cover-art-archive/schema.js
Normal 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 Archive’s 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 image’s 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 release’s
|
||||||
|
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
|
||||||
|
}
|
||||||
|
`;
|
||||||
61
src/extensions/fanart-tv/client.js
Normal file
61
src/extensions/fanart-tv/client.js
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/extensions/fanart-tv/index.js
Normal file
37
src/extensions/fanart-tv/index.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
65
src/extensions/fanart-tv/loader.js
Normal file
65
src/extensions/fanart-tv/loader.js
Normal 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;
|
||||||
|
}
|
||||||
64
src/extensions/fanart-tv/resolvers.js
Normal file
64
src/extensions/fanart-tv/resolvers.js
Normal 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]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
197
src/extensions/fanart-tv/schema.js
Normal file
197
src/extensions/fanart-tv/schema.js
Normal 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 image’s 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 artist’s logo or name, with
|
||||||
|
transparent backgrounds.
|
||||||
|
"""
|
||||||
|
logos: [FanArtImage]
|
||||||
|
|
||||||
|
"""
|
||||||
|
A list of 800x310 PNG images containing the artist’s 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 label’s 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
18
src/extensions/index.js
Normal 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;
|
||||||
|
}
|
||||||
54
src/extensions/mediawiki/client.js
Normal file
54
src/extensions/mediawiki/client.js
Normal 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];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/extensions/mediawiki/index.js
Normal file
37
src/extensions/mediawiki/index.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
30
src/extensions/mediawiki/loader.js
Normal file
30
src/extensions/mediawiki/loader.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/extensions/mediawiki/resolvers.js
Normal file
80
src/extensions/mediawiki/resolvers.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
168
src/extensions/mediawiki/schema.js
Normal file
168
src/extensions/mediawiki/schema.js
Normal 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 artist’s 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 instrument’s 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 label’s 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 place’s 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]!
|
||||||
|
}
|
||||||
|
`;
|
||||||
78
src/extensions/the-audio-db/client.js
Normal file
78
src/extensions/the-audio-db/client.js
Normal 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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/extensions/the-audio-db/index.js
Normal file
37
src/extensions/the-audio-db/index.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
35
src/extensions/the-audio-db/loader.js
Normal file
35
src/extensions/the-audio-db/loader.js
Normal 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/extensions/the-audio-db/resolvers.js
Normal file
118
src/extensions/the-audio-db/resolvers.js
Normal 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,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
372
src/extensions/the-audio-db/schema.js
Normal file
372
src/extensions/the-audio-db/schema.js
Normal 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 image’s 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 artist’s 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 album’s rating as determined by user votes, out of 10.
|
||||||
|
"""
|
||||||
|
score: Float
|
||||||
|
|
||||||
|
"""
|
||||||
|
The number of users who voted to determine the album’s 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 track’s rating as determined by user votes, out of 10.
|
||||||
|
"""
|
||||||
|
score: Float
|
||||||
|
|
||||||
|
"""
|
||||||
|
The number of users who voted to determine the album’s 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
|
||||||
|
}
|
||||||
|
`;
|
||||||
121
src/index.js
121
src/index.js
|
|
@ -1,43 +1,104 @@
|
||||||
import express from 'express'
|
import express from 'express';
|
||||||
import graphqlHTTP from 'express-graphql'
|
import ExpressGraphQL from 'express-graphql';
|
||||||
import compression from 'compression'
|
import compression from 'compression';
|
||||||
import MusicBrainz, { CoverArtArchive } from './api'
|
import cors from 'cors';
|
||||||
import schema from './schema'
|
import MusicBrainz from './api/index.js';
|
||||||
import createLoaders from './loaders'
|
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) => ({
|
const formatError = (err) => ({
|
||||||
message: err.message,
|
message: err.message,
|
||||||
locations: err.locations,
|
locations: err.locations,
|
||||||
stack: err.stack
|
stack: err.stack,
|
||||||
})
|
});
|
||||||
|
|
||||||
const middleware = ({
|
const defaultExtensions = [
|
||||||
|
'graphbrainz/extensions/cover-art-archive',
|
||||||
|
'graphbrainz/extensions/fanart-tv',
|
||||||
|
'graphbrainz/extensions/mediawiki',
|
||||||
|
'graphbrainz/extensions/the-audio-db',
|
||||||
|
];
|
||||||
|
|
||||||
|
function middleware({
|
||||||
client = new MusicBrainz(),
|
client = new MusicBrainz(),
|
||||||
coverArtClient = new CoverArtArchive(),
|
extensions = process.env.GRAPHBRAINZ_EXTENSIONS
|
||||||
...options
|
? JSON.parse(process.env.GRAPHBRAINZ_EXTENSIONS)
|
||||||
} = {}) => {
|
: defaultExtensions,
|
||||||
const DEV = process.env.NODE_ENV !== 'production'
|
...middlewareOptions
|
||||||
const graphiql = DEV || process.env.GRAPHBRAINZ_GRAPHIQL === 'true'
|
} = {}) {
|
||||||
const loaders = createLoaders(client, coverArtClient)
|
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({
|
return graphqlHTTP({
|
||||||
schema,
|
schema,
|
||||||
context: { client, coverArtClient, loaders },
|
context,
|
||||||
pretty: DEV,
|
pretty: DEV,
|
||||||
graphiql,
|
graphiql,
|
||||||
formatError: DEV ? formatError : undefined,
|
customFormatErrorFn: DEV ? formatError : undefined,
|
||||||
...options
|
...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
|
async function start(options) {
|
||||||
|
const dotenv = await import('dotenv');
|
||||||
if (require.main === module) {
|
dotenv.config({ silent: true });
|
||||||
require('dotenv').config({ silent: true })
|
const app = express();
|
||||||
const app = express()
|
const port = process.env.PORT || 3000;
|
||||||
const port = process.env.PORT || 3000
|
const route = process.env.GRAPHBRAINZ_PATH || '/';
|
||||||
const route = process.env.GRAPHBRAINZ_PATH || '/'
|
const corsOptions = {
|
||||||
app.use(compression())
|
origin: process.env.GRAPHBRAINZ_CORS_ORIGIN || false,
|
||||||
app.use(route, middleware())
|
methods: 'HEAD,GET,POST',
|
||||||
app.listen(port)
|
};
|
||||||
console.log(`Listening on port ${port}.`)
|
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,
|
||||||
|
};
|
||||||
|
|
|
||||||
143
src/loaders.js
143
src/loaders.js
|
|
@ -1,105 +1,92 @@
|
||||||
import DataLoader from 'dataloader'
|
import createDebug from 'debug';
|
||||||
import LRUCache from 'lru-cache'
|
import DataLoader from 'dataloader';
|
||||||
import { toPlural } from './types/helpers'
|
import LRUCache from 'lru-cache';
|
||||||
|
import { ONE_DAY, toPlural } from './util.js';
|
||||||
|
|
||||||
const debug = require('debug')('graphbrainz:loaders')
|
const debug = createDebug('graphbrainz:loaders');
|
||||||
const ONE_DAY = 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
export default function createLoaders (client, coverArtClient) {
|
export default function createLoaders(client) {
|
||||||
// All loaders share a single LRU cache that will remember 8192 responses,
|
// All loaders share a single LRU cache that will remember 8192 responses,
|
||||||
// each cached for 1 day.
|
// each cached for 1 day.
|
||||||
const cache = LRUCache({
|
const cache = new LRUCache({
|
||||||
max: parseInt(process.env.GRAPHBRAINZ_CACHE_SIZE || 8192, 10),
|
max: parseInt(process.env.GRAPHBRAINZ_CACHE_SIZE || 8192, 10),
|
||||||
maxAge: parseInt(process.env.GRAPHBRAINZ_CACHE_TTL || ONE_DAY, 10),
|
maxAge: parseInt(process.env.GRAPHBRAINZ_CACHE_TTL || ONE_DAY, 10),
|
||||||
dispose(key) {
|
dispose(key) {
|
||||||
debug(`Removed from cache. key=${key}`)
|
debug(`Removed from cache. key=${key}`);
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
// Make the cache Map-like.
|
// Make the cache Map-like.
|
||||||
cache.delete = cache.del
|
cache.delete = cache.del;
|
||||||
cache.clear = cache.reset
|
cache.clear = cache.reset;
|
||||||
|
|
||||||
const lookup = new DataLoader(keys => {
|
const lookup = new DataLoader(
|
||||||
return Promise.all(keys.map(key => {
|
(keys) => {
|
||||||
const [ entityType, id, params = {} ] = key
|
return Promise.all(
|
||||||
return client.lookup(entityType, id, params).then(entity => {
|
keys.map((key) => {
|
||||||
|
const [entityType, id, params = {}] = key;
|
||||||
|
return client.lookup(entityType, id, params).then((entity) => {
|
||||||
if (entity) {
|
if (entity) {
|
||||||
// Store the entity type so we can determine what type of object this
|
// Store the entity type so we can determine what type of object this
|
||||||
// is elsewhere in the code.
|
// is elsewhere in the code.
|
||||||
entity._type = entityType
|
entity._type = entityType;
|
||||||
}
|
}
|
||||||
return entity
|
return entity;
|
||||||
|
});
|
||||||
})
|
})
|
||||||
}))
|
);
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
|
batch: false,
|
||||||
cacheKeyFn: (key) => client.getLookupURL(...key),
|
cacheKeyFn: (key) => client.getLookupURL(...key),
|
||||||
cacheMap: cache
|
cacheMap: cache,
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const browse = new DataLoader(keys => {
|
const browse = new DataLoader(
|
||||||
return Promise.all(keys.map(key => {
|
(keys) => {
|
||||||
const [ entityType, params = {} ] = key
|
return Promise.all(
|
||||||
return client.browse(entityType, params).then(list => {
|
keys.map((key) => {
|
||||||
list[toPlural(entityType)].forEach(entity => {
|
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
|
// Store the entity type so we can determine what type of object this
|
||||||
// is elsewhere in the code.
|
// is elsewhere in the code.
|
||||||
entity._type = entityType
|
entity._type = entityType;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
})
|
})
|
||||||
return list
|
);
|
||||||
})
|
},
|
||||||
}))
|
{
|
||||||
}, {
|
batch: false,
|
||||||
cacheKeyFn: (key) => client.getBrowseURL(...key),
|
cacheKeyFn: (key) => client.getBrowseURL(...key),
|
||||||
cacheMap: cache
|
cacheMap: cache,
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const search = new DataLoader(keys => {
|
const search = new DataLoader(
|
||||||
return Promise.all(keys.map(key => {
|
(keys) => {
|
||||||
const [ entityType, query, params = {} ] = key
|
return Promise.all(
|
||||||
return client.search(entityType, query, params).then(list => {
|
keys.map((key) => {
|
||||||
list[toPlural(entityType)].forEach(entity => {
|
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
|
// Store the entity type so we can determine what type of object this
|
||||||
// is elsewhere in the code.
|
// is elsewhere in the code.
|
||||||
entity._type = entityType
|
entity._type = entityType;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
})
|
})
|
||||||
return list
|
);
|
||||||
})
|
},
|
||||||
}))
|
{
|
||||||
}, {
|
batch: false,
|
||||||
cacheKeyFn: key => client.getSearchURL(...key),
|
cacheKeyFn: (key) => client.getSearchURL(...key),
|
||||||
cacheMap: cache
|
cacheMap: cache,
|
||||||
})
|
|
||||||
|
|
||||||
const coverArt = new DataLoader(keys => {
|
|
||||||
return Promise.all(keys.map(key => {
|
|
||||||
const [ entityType, id ] = key
|
|
||||||
return coverArtClient.images(...key).catch(err => {
|
|
||||||
if (err.statusCode === 404) {
|
|
||||||
return { images: [] }
|
|
||||||
}
|
}
|
||||||
throw err
|
);
|
||||||
}).then(coverArt => {
|
|
||||||
coverArt._parentType = entityType
|
|
||||||
coverArt._parentID = id
|
|
||||||
if (entityType === 'release') {
|
|
||||||
coverArt._release = id
|
|
||||||
} else {
|
|
||||||
coverArt._release = coverArt.release && coverArt.release.split('/').pop()
|
|
||||||
}
|
|
||||||
return coverArt
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}, {
|
|
||||||
cacheKeyFn: key => `cover-art/${coverArtClient.getImagesURL(...key)}`,
|
|
||||||
cacheMap: cache
|
|
||||||
})
|
|
||||||
|
|
||||||
const coverArtURL = new DataLoader(keys => {
|
return { lookup, browse, search };
|
||||||
return Promise.all(keys.map(key => coverArtClient.imageURL(...key)))
|
|
||||||
}, {
|
|
||||||
cacheKeyFn: key => `cover-art/url/${coverArtClient.getImageURL(...key)}`,
|
|
||||||
cacheMap: cache
|
|
||||||
})
|
|
||||||
|
|
||||||
return { lookup, browse, search, coverArt, coverArtURL }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { GraphQLObjectType, GraphQLString } from 'graphql'
|
import GraphQL from 'graphql';
|
||||||
import { forwardConnectionArgs } from 'graphql-relay'
|
import GraphQLRelay from 'graphql-relay';
|
||||||
import { resolveBrowse } from '../resolvers'
|
import { resolveBrowse } from '../resolvers.js';
|
||||||
import {
|
import {
|
||||||
MBID,
|
MBID,
|
||||||
AreaConnection,
|
AreaConnection,
|
||||||
|
|
@ -15,62 +15,66 @@ import {
|
||||||
RecordingConnection,
|
RecordingConnection,
|
||||||
ReleaseConnection,
|
ReleaseConnection,
|
||||||
ReleaseGroupConnection,
|
ReleaseGroupConnection,
|
||||||
WorkConnection
|
WorkConnection,
|
||||||
} from '../types'
|
} from '../types/index.js';
|
||||||
import { toWords, releaseGroupType, releaseStatus } from '../types/helpers'
|
import { releaseGroupType, releaseStatus } from '../types/helpers.js';
|
||||||
|
import { toWords } from '../util.js';
|
||||||
|
|
||||||
|
const { GraphQLObjectType, GraphQLString } = GraphQL;
|
||||||
|
const { forwardConnectionArgs } = GraphQLRelay;
|
||||||
|
|
||||||
const area = {
|
const area = {
|
||||||
type: MBID,
|
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 = {
|
const artist = {
|
||||||
type: MBID,
|
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 = {
|
const collection = {
|
||||||
type: MBID,
|
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 = {
|
const event = {
|
||||||
type: MBID,
|
type: MBID,
|
||||||
description: 'The MBID of an event to which the entity is linked.'
|
description: 'The MBID of an event to which the entity is linked.',
|
||||||
}
|
};
|
||||||
const label = {
|
const label = {
|
||||||
type: MBID,
|
type: MBID,
|
||||||
description: 'The MBID of a label to which the entity is linked.'
|
description: 'The MBID of a label to which the entity is linked.',
|
||||||
}
|
};
|
||||||
const place = {
|
const place = {
|
||||||
type: MBID,
|
type: MBID,
|
||||||
description: 'The MBID of a place to which the entity is linked.'
|
description: 'The MBID of a place to which the entity is linked.',
|
||||||
}
|
};
|
||||||
const recording = {
|
const recording = {
|
||||||
type: MBID,
|
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 = {
|
const release = {
|
||||||
type: MBID,
|
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 = {
|
const releaseGroup = {
|
||||||
type: MBID,
|
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 = {
|
const work = {
|
||||||
type: MBID,
|
type: MBID,
|
||||||
description: 'The MBID of a work to which the entity is linked.'
|
description: 'The MBID of a work to which the entity is linked.',
|
||||||
}
|
};
|
||||||
|
|
||||||
function createBrowseField(connectionType, args) {
|
function createBrowseField(connectionType, args) {
|
||||||
const typeName = toWords(connectionType.name.slice(0, -10))
|
const typeName = toWords(connectionType.name.slice(0, -10));
|
||||||
return {
|
return {
|
||||||
type: connectionType,
|
type: connectionType,
|
||||||
description: `Browse ${typeName} entities linked to the given arguments.`,
|
description: `Browse ${typeName} entities linked to the given arguments.`,
|
||||||
args: {
|
args: {
|
||||||
...args,
|
...args,
|
||||||
...forwardConnectionArgs
|
...forwardConnectionArgs,
|
||||||
},
|
},
|
||||||
resolve: resolveBrowse
|
resolve: resolveBrowse,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BrowseQuery = new GraphQLObjectType({
|
export const BrowseQuery = new GraphQLObjectType({
|
||||||
|
|
@ -79,7 +83,7 @@ export const BrowseQuery = new GraphQLObjectType({
|
||||||
entity.`,
|
entity.`,
|
||||||
fields: {
|
fields: {
|
||||||
areas: createBrowseField(AreaConnection, {
|
areas: createBrowseField(AreaConnection, {
|
||||||
collection
|
collection,
|
||||||
}),
|
}),
|
||||||
artists: createBrowseField(ArtistConnection, {
|
artists: createBrowseField(ArtistConnection, {
|
||||||
area,
|
area,
|
||||||
|
|
@ -87,14 +91,14 @@ entity.`,
|
||||||
recording,
|
recording,
|
||||||
release,
|
release,
|
||||||
releaseGroup,
|
releaseGroup,
|
||||||
work
|
work,
|
||||||
}),
|
}),
|
||||||
collections: createBrowseField(CollectionConnection, {
|
collections: createBrowseField(CollectionConnection, {
|
||||||
area,
|
area,
|
||||||
artist,
|
artist,
|
||||||
editor: {
|
editor: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'The username of the editor who created the collection.'
|
description: 'The username of the editor who created the collection.',
|
||||||
},
|
},
|
||||||
event,
|
event,
|
||||||
label,
|
label,
|
||||||
|
|
@ -102,22 +106,22 @@ entity.`,
|
||||||
recording,
|
recording,
|
||||||
release,
|
release,
|
||||||
releaseGroup,
|
releaseGroup,
|
||||||
work
|
work,
|
||||||
}),
|
}),
|
||||||
events: createBrowseField(EventConnection, {
|
events: createBrowseField(EventConnection, {
|
||||||
area,
|
area,
|
||||||
artist,
|
artist,
|
||||||
collection,
|
collection,
|
||||||
place
|
place,
|
||||||
}),
|
}),
|
||||||
labels: createBrowseField(LabelConnection, {
|
labels: createBrowseField(LabelConnection, {
|
||||||
area,
|
area,
|
||||||
collection,
|
collection,
|
||||||
release
|
release,
|
||||||
}),
|
}),
|
||||||
places: createBrowseField(PlaceConnection, {
|
places: createBrowseField(PlaceConnection, {
|
||||||
area,
|
area,
|
||||||
collection
|
collection,
|
||||||
}),
|
}),
|
||||||
recordings: createBrowseField(RecordingConnection, {
|
recordings: createBrowseField(RecordingConnection, {
|
||||||
artist,
|
artist,
|
||||||
|
|
@ -125,9 +129,9 @@ entity.`,
|
||||||
isrc: {
|
isrc: {
|
||||||
type: ISRC,
|
type: ISRC,
|
||||||
description: `The [International Standard Recording Code](https://musicbrainz.org/doc/ISRC)
|
description: `The [International Standard Recording Code](https://musicbrainz.org/doc/ISRC)
|
||||||
(ISRC) of the recording.`
|
(ISRC) of the recording.`,
|
||||||
},
|
},
|
||||||
release
|
release,
|
||||||
}),
|
}),
|
||||||
releases: createBrowseField(ReleaseConnection, {
|
releases: createBrowseField(ReleaseConnection, {
|
||||||
area,
|
area,
|
||||||
|
|
@ -136,28 +140,28 @@ entity.`,
|
||||||
discID: {
|
discID: {
|
||||||
type: DiscID,
|
type: DiscID,
|
||||||
description: `A [disc ID](https://musicbrainz.org/doc/Disc_ID)
|
description: `A [disc ID](https://musicbrainz.org/doc/Disc_ID)
|
||||||
associated with the release.`
|
associated with the release.`,
|
||||||
},
|
},
|
||||||
label,
|
label,
|
||||||
recording,
|
recording,
|
||||||
releaseGroup,
|
releaseGroup,
|
||||||
track: {
|
track: {
|
||||||
type: MBID,
|
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: {
|
trackArtist: {
|
||||||
type: MBID,
|
type: MBID,
|
||||||
description: `The MBID of an artist that appears on a track in the
|
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.`,
|
||||||
},
|
},
|
||||||
type: releaseGroupType,
|
type: releaseGroupType,
|
||||||
status: releaseStatus
|
status: releaseStatus,
|
||||||
}),
|
}),
|
||||||
releaseGroups: createBrowseField(ReleaseGroupConnection, {
|
releaseGroups: createBrowseField(ReleaseGroupConnection, {
|
||||||
artist,
|
artist,
|
||||||
collection,
|
collection,
|
||||||
release,
|
release,
|
||||||
type: releaseGroupType
|
type: releaseGroupType,
|
||||||
}),
|
}),
|
||||||
works: createBrowseField(WorkConnection, {
|
works: createBrowseField(WorkConnection, {
|
||||||
artist,
|
artist,
|
||||||
|
|
@ -165,18 +169,19 @@ release, but is not included in the credits for the release itself.`
|
||||||
iswc: {
|
iswc: {
|
||||||
type: ISWC,
|
type: ISWC,
|
||||||
description: `The [International Standard Musical Work Code](https://musicbrainz.org/doc/ISWC)
|
description: `The [International Standard Musical Work Code](https://musicbrainz.org/doc/ISWC)
|
||||||
(ISWC) of the work.`
|
(ISWC) of the work.`,
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const browse = {
|
export const browse = {
|
||||||
type: BrowseQuery,
|
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,
|
// We only have work to do once we know what entity types are being requested,
|
||||||
// so this can just resolve to an empty object.
|
// so this can just resolve to an empty object.
|
||||||
resolve: () => ({})
|
resolve: () => ({}),
|
||||||
}
|
};
|
||||||
|
|
||||||
export default BrowseQuery
|
export default BrowseQuery;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { LookupQuery, lookup } from './lookup'
|
export { LookupQuery, lookup } from './lookup.js';
|
||||||
export { BrowseQuery, browse } from './browse'
|
export { BrowseQuery, browse } from './browse.js';
|
||||||
export { SearchQuery, search } from './search'
|
export { SearchQuery, search } from './search.js';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { GraphQLObjectType, GraphQLNonNull } from 'graphql'
|
import GraphQL from 'graphql';
|
||||||
import { resolveLookup } from '../resolvers'
|
import { resolveLookup } from '../resolvers.js';
|
||||||
import { mbid, toWords } from '../types/helpers'
|
import { mbid } from '../types/helpers.js';
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
Artist,
|
Artist,
|
||||||
|
|
@ -18,17 +18,20 @@ import {
|
||||||
Series,
|
Series,
|
||||||
URL,
|
URL,
|
||||||
URLString,
|
URLString,
|
||||||
Work
|
Work,
|
||||||
} from '../types'
|
} from '../types/index.js';
|
||||||
|
import { toWords } from '../util.js';
|
||||||
|
|
||||||
|
const { GraphQLObjectType, GraphQLNonNull } = GraphQL;
|
||||||
|
|
||||||
function createLookupField(entity, args) {
|
function createLookupField(entity, args) {
|
||||||
const typeName = toWords(entity.name)
|
const typeName = toWords(entity.name);
|
||||||
return {
|
return {
|
||||||
type: entity,
|
type: entity,
|
||||||
description: `Look up a specific ${typeName} by its MBID.`,
|
description: `Look up a specific ${typeName} by its MBID.`,
|
||||||
args: { mbid, ...args },
|
args: { mbid, ...args },
|
||||||
resolve: resolveLookup
|
resolve: resolveLookup,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LookupQuery = new GraphQLObjectType({
|
export const LookupQuery = new GraphQLObjectType({
|
||||||
|
|
@ -45,12 +48,12 @@ export const LookupQuery = new GraphQLObjectType({
|
||||||
discID: {
|
discID: {
|
||||||
type: new GraphQLNonNull(DiscID),
|
type: new GraphQLNonNull(DiscID),
|
||||||
description: `The [disc ID](https://musicbrainz.org/doc/Disc_ID)
|
description: `The [disc ID](https://musicbrainz.org/doc/Disc_ID)
|
||||||
of the disc.`
|
of the disc.`,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
resolve: (root, { discID }, { loaders }, info) => {
|
resolve: (root, { discID }, { loaders }, info) => {
|
||||||
return loaders.lookup.load(['discid', discID])
|
return loaders.lookup.load(['discid', discID]);
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
event: createLookupField(Event),
|
event: createLookupField(Event),
|
||||||
instrument: createLookupField(Instrument),
|
instrument: createLookupField(Instrument),
|
||||||
|
|
@ -65,23 +68,23 @@ of the disc.`
|
||||||
...mbid,
|
...mbid,
|
||||||
// Remove the non-null requirement that is usually on the `mbid` field
|
// Remove the non-null requirement that is usually on the `mbid` field
|
||||||
// so that URLs can be looked up by `resource`.
|
// so that URLs can be looked up by `resource`.
|
||||||
type: MBID
|
type: MBID,
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
type: URLString,
|
type: URLString,
|
||||||
description: 'The web address of the URL entity to look up.'
|
description: 'The web address of the URL entity to look up.',
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
work: createLookupField(Work)
|
work: createLookupField(Work),
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const lookup = {
|
export const lookup = {
|
||||||
type: LookupQuery,
|
type: LookupQuery,
|
||||||
description: 'Perform a lookup of a MusicBrainz entity by its MBID.',
|
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,
|
// We only have work to do once we know what entity types are being requested,
|
||||||
// so this can just resolve to an empty object.
|
// so this can just resolve to an empty object.
|
||||||
resolve: () => ({})
|
resolve: () => ({}),
|
||||||
}
|
};
|
||||||
|
|
||||||
export default LookupQuery
|
export default LookupQuery;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { GraphQLObjectType, GraphQLNonNull, GraphQLString } from 'graphql'
|
import GraphQL from 'graphql';
|
||||||
import { forwardConnectionArgs } from 'graphql-relay'
|
import GraphQLRelay from 'graphql-relay';
|
||||||
import { resolveSearch } from '../resolvers'
|
import { resolveSearch } from '../resolvers.js';
|
||||||
import {
|
import {
|
||||||
AreaConnection,
|
AreaConnection,
|
||||||
ArtistConnection,
|
ArtistConnection,
|
||||||
|
|
@ -12,12 +12,15 @@ import {
|
||||||
ReleaseConnection,
|
ReleaseConnection,
|
||||||
ReleaseGroupConnection,
|
ReleaseGroupConnection,
|
||||||
SeriesConnection,
|
SeriesConnection,
|
||||||
WorkConnection
|
WorkConnection,
|
||||||
} from '../types'
|
} from '../types/index.js';
|
||||||
import { toWords } from '../types/helpers'
|
import { toWords } from '../util.js';
|
||||||
|
|
||||||
|
const { GraphQLObjectType, GraphQLNonNull, GraphQLString } = GraphQL;
|
||||||
|
const { forwardConnectionArgs } = GraphQLRelay;
|
||||||
|
|
||||||
function createSearchField(connectionType) {
|
function createSearchField(connectionType) {
|
||||||
const typeName = toWords(connectionType.name.slice(0, -10))
|
const typeName = toWords(connectionType.name.slice(0, -10));
|
||||||
return {
|
return {
|
||||||
type: connectionType,
|
type: connectionType,
|
||||||
description: `Search for ${typeName} entities matching the given query.`,
|
description: `Search for ${typeName} entities matching the given query.`,
|
||||||
|
|
@ -25,12 +28,12 @@ function createSearchField (connectionType) {
|
||||||
query: {
|
query: {
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
type: new GraphQLNonNull(GraphQLString),
|
||||||
description: `The query terms, in Lucene search syntax. See [examples
|
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: resolveSearch
|
resolve: resolveSearch,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchQuery = new GraphQLObjectType({
|
export const SearchQuery = new GraphQLObjectType({
|
||||||
|
|
@ -47,16 +50,16 @@ export const SearchQuery = new GraphQLObjectType({
|
||||||
releases: createSearchField(ReleaseConnection),
|
releases: createSearchField(ReleaseConnection),
|
||||||
releaseGroups: createSearchField(ReleaseGroupConnection),
|
releaseGroups: createSearchField(ReleaseGroupConnection),
|
||||||
series: createSearchField(SeriesConnection),
|
series: createSearchField(SeriesConnection),
|
||||||
works: createSearchField(WorkConnection)
|
works: createSearchField(WorkConnection),
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const search = {
|
export const search = {
|
||||||
type: SearchQuery,
|
type: SearchQuery,
|
||||||
description: 'Search for MusicBrainz entities using Lucene query syntax.',
|
description: 'Search for MusicBrainz entities using Lucene query syntax.',
|
||||||
// We only have work to do once we know what entity types are being requested,
|
// We only have work to do once we know what entity types are being requested,
|
||||||
// so this can just resolve to an empty object.
|
// so this can just resolve to an empty object.
|
||||||
resolve: () => ({})
|
resolve: () => ({}),
|
||||||
}
|
};
|
||||||
|
|
||||||
export default SearchQuery
|
export default SearchQuery;
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,103 @@
|
||||||
const debug = require('debug')('graphbrainz:rate-limit')
|
import createDebug from 'debug';
|
||||||
|
|
||||||
|
const debug = createDebug('graphbrainz:rate-limit');
|
||||||
|
|
||||||
export default class RateLimit {
|
export default class RateLimit {
|
||||||
constructor({
|
constructor({
|
||||||
limit = 1,
|
limit = 1,
|
||||||
period = 1000,
|
period = 1000,
|
||||||
concurrency = limit || 1,
|
concurrency = limit || 1,
|
||||||
defaultPriority = 1
|
defaultPriority = 1,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
this.limit = limit
|
this.limit = limit;
|
||||||
this.period = period
|
this.period = period;
|
||||||
this.defaultPriority = defaultPriority
|
this.defaultPriority = defaultPriority;
|
||||||
this.concurrency = concurrency
|
this.concurrency = concurrency;
|
||||||
this.queues = []
|
this.queues = [];
|
||||||
this.numPending = 0
|
this.numPending = 0;
|
||||||
this.periodStart = null
|
this.periodStart = null;
|
||||||
this.periodCapacity = this.limit
|
this.periodCapacity = this.limit;
|
||||||
this.timer = null
|
this.timer = null;
|
||||||
this.pendingFlush = false
|
this.pendingFlush = false;
|
||||||
this.prevTaskID = null
|
this.prevTaskID = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTaskID(prevTaskID = this.prevTaskID) {
|
nextTaskID(prevTaskID = this.prevTaskID) {
|
||||||
const id = (prevTaskID || 0) + 1
|
const id = (prevTaskID || 0) + 1;
|
||||||
this.prevTaskID = id
|
this.prevTaskID = id;
|
||||||
return id
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueue(fn, args, priority = this.defaultPriority) {
|
enqueue(fn, args, priority = this.defaultPriority) {
|
||||||
priority = Math.max(0, priority)
|
priority = Math.max(0, priority);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const queue = this.queues[priority] = this.queues[priority] || []
|
const queue = (this.queues[priority] = this.queues[priority] || []);
|
||||||
const id = this.nextTaskID()
|
const id = this.nextTaskID();
|
||||||
debug(`Enqueuing task. id=${id} priority=${priority}`)
|
debug(`Enqueuing task. id=${id} priority=${priority}`);
|
||||||
queue.push({ fn, args, resolve, reject, id })
|
queue.push({ fn, args, resolve, reject, id });
|
||||||
if (!this.pendingFlush) {
|
if (!this.pendingFlush) {
|
||||||
this.pendingFlush = true
|
this.pendingFlush = true;
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
this.pendingFlush = false
|
this.pendingFlush = false;
|
||||||
this.flush()
|
this.flush();
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dequeue() {
|
dequeue() {
|
||||||
let task
|
let task;
|
||||||
for (let i = this.queues.length - 1; i >= 0; i--) {
|
for (let i = this.queues.length - 1; i >= 0; i--) {
|
||||||
const queue = this.queues[i]
|
const queue = this.queues[i];
|
||||||
if (queue && queue.length) {
|
if (queue && queue.length) {
|
||||||
task = queue.shift()
|
task = queue.shift();
|
||||||
}
|
}
|
||||||
if (!queue || !queue.length) {
|
if (!queue || !queue.length) {
|
||||||
this.queues.length = i
|
this.queues.length = i;
|
||||||
}
|
}
|
||||||
if (task) {
|
if (task) {
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return task
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
flush() {
|
flush() {
|
||||||
if (this.numPending < this.concurrency && this.periodCapacity > 0) {
|
if (this.numPending < this.concurrency && this.periodCapacity > 0) {
|
||||||
const task = this.dequeue()
|
const task = this.dequeue();
|
||||||
if (task) {
|
if (task) {
|
||||||
const { resolve, reject, fn, args, id } = task
|
const { resolve, reject, fn, args, id } = task;
|
||||||
if (this.timer == null) {
|
if (this.timer == null) {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
let timeout = this.period
|
let timeout = this.period;
|
||||||
if (this.periodStart != null) {
|
if (this.periodStart != null) {
|
||||||
const delay = now - (this.periodStart + timeout)
|
const delay = now - (this.periodStart + timeout);
|
||||||
if (delay > 0 && delay <= timeout) {
|
if (delay > 0 && delay <= timeout) {
|
||||||
timeout -= delay
|
timeout -= delay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.periodStart = now
|
this.periodStart = now;
|
||||||
this.timer = setTimeout(() => {
|
this.timer = setTimeout(() => {
|
||||||
this.timer = null
|
this.timer = null;
|
||||||
this.periodCapacity = this.limit
|
this.periodCapacity = this.limit;
|
||||||
this.flush()
|
this.flush();
|
||||||
}, timeout)
|
}, timeout);
|
||||||
}
|
}
|
||||||
this.numPending += 1
|
this.numPending += 1;
|
||||||
this.periodCapacity -= 1
|
this.periodCapacity -= 1;
|
||||||
const onResolve = (value) => {
|
const onResolve = (value) => {
|
||||||
this.numPending -= 1
|
this.numPending -= 1;
|
||||||
resolve(value)
|
resolve(value);
|
||||||
this.flush()
|
this.flush();
|
||||||
}
|
};
|
||||||
const onReject = (err) => {
|
const onReject = (err) => {
|
||||||
this.numPending -= 1
|
this.numPending -= 1;
|
||||||
reject(err)
|
reject(err);
|
||||||
this.flush()
|
this.flush();
|
||||||
}
|
};
|
||||||
debug(`Running task. id=${id}`)
|
debug(`Running task. id=${id}`);
|
||||||
fn(...args).then(onResolve, onReject)
|
fn(...args).then(onResolve, onReject);
|
||||||
this.flush()
|
this.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
263
src/resolvers.js
263
src/resolvers.js
|
|
@ -1,39 +1,40 @@
|
||||||
import { toDashed, toSingular } from './types/helpers'
|
import GraphQLRelay from 'graphql-relay';
|
||||||
import {
|
import { getFields, extendIncludes, toDashed, toSingular } from './util.js';
|
||||||
|
|
||||||
|
const {
|
||||||
getOffsetWithDefault,
|
getOffsetWithDefault,
|
||||||
connectionFromArray,
|
connectionFromArray,
|
||||||
connectionFromArraySlice
|
connectionFromArraySlice,
|
||||||
} from 'graphql-relay'
|
} = GraphQLRelay;
|
||||||
import { getFields, extendIncludes } from './util'
|
|
||||||
|
|
||||||
export function includeRelationships(params, info, fragments = info.fragments) {
|
export function includeRelationships(params, info, fragments = info.fragments) {
|
||||||
let fields = getFields(info, fragments)
|
let fields = getFields(info, fragments);
|
||||||
if (info.fieldName !== 'relationships') {
|
if (info.fieldName !== 'relationships') {
|
||||||
if (fields.relationships) {
|
if (fields.relationships) {
|
||||||
fields = getFields(fields.relationships, fragments)
|
fields = getFields(fields.relationships, fragments);
|
||||||
} else {
|
} else {
|
||||||
if (fields.edges) {
|
if (fields.edges) {
|
||||||
fields = getFields(fields.edges, fragments)
|
fields = getFields(fields.edges, fragments);
|
||||||
if (fields.node) {
|
if (fields.node) {
|
||||||
return includeRelationships(params, fields.node, fragments)
|
return includeRelationships(params, fields.node, fragments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return params
|
return params;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fields) {
|
if (fields) {
|
||||||
const relationships = Object.keys(fields)
|
const relationships = Object.keys(fields);
|
||||||
const includeRels = relationships.map(field => {
|
const includeRels = relationships.map((field) => {
|
||||||
return `${toDashed(toSingular(field))}-rels`
|
return `${toDashed(toSingular(field))}-rels`;
|
||||||
})
|
});
|
||||||
if (includeRels.length) {
|
if (includeRels.length) {
|
||||||
params = {
|
params = {
|
||||||
...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) {
|
||||||
|
|
@ -42,153 +43,174 @@ export function includeSubqueries (params, info, fragments = info.fragments) {
|
||||||
artistCredit: ['artist-credits'],
|
artistCredit: ['artist-credits'],
|
||||||
artistCredits: ['artist-credits'],
|
artistCredits: ['artist-credits'],
|
||||||
isrcs: ['isrcs'],
|
isrcs: ['isrcs'],
|
||||||
media: ['media', 'discids'],
|
media: ['media'],
|
||||||
|
'media.discs': ['discids'],
|
||||||
|
'media.tracks': ['recordings'],
|
||||||
rating: ['ratings'],
|
rating: ['ratings'],
|
||||||
tags: ['tags']
|
tags: ['tags'],
|
||||||
}
|
};
|
||||||
let fields = getFields(info, fragments)
|
let fields = getFields(info, fragments, 1);
|
||||||
const include = []
|
const include = [];
|
||||||
for (const key in subqueryIncludes) {
|
for (const key in subqueryIncludes) {
|
||||||
if (fields[key]) {
|
const field = fields[key];
|
||||||
const value = subqueryIncludes[key]
|
if (field) {
|
||||||
include.push(...value)
|
const value = subqueryIncludes[key];
|
||||||
|
include.push(...value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
params = {
|
params = {
|
||||||
...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) {
|
return params;
|
||||||
fields = getFields(fields.edges, fragments)
|
|
||||||
if (fields.node) {
|
|
||||||
params = includeSubqueries(params, fields.node, fragments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLookup(root, { mbid, ...params }, { loaders }, info) {
|
export function resolveLookup(root, { mbid, ...params }, { loaders }, info) {
|
||||||
if (!mbid && !params.resource) {
|
if (!mbid && !params.resource) {
|
||||||
throw new Error('Lookups by a field other than MBID must provide: resource')
|
throw new Error(
|
||||||
|
'Lookups by a field other than MBID must provide: resource'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const entityType = toDashed(info.fieldName)
|
const entityType = toDashed(info.fieldName);
|
||||||
params = includeSubqueries(params, info)
|
params = includeSubqueries(params, info);
|
||||||
params = includeRelationships(params, info)
|
params = includeRelationships(params, info);
|
||||||
return loaders.lookup.load([entityType, mbid, params])
|
return loaders.lookup.load([entityType, mbid, params]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveBrowse (root, {
|
export function resolveBrowse(
|
||||||
first,
|
root,
|
||||||
after,
|
{ first, after, type = [], status = [], discID, isrc, iswc, ...args },
|
||||||
type = [],
|
{ loaders },
|
||||||
status = [],
|
info
|
||||||
discID,
|
) {
|
||||||
isrc,
|
const pluralName = toDashed(info.fieldName);
|
||||||
iswc,
|
const singularName = toSingular(pluralName);
|
||||||
...args
|
|
||||||
}, { loaders }, info) {
|
|
||||||
const pluralName = toDashed(info.fieldName)
|
|
||||||
const singularName = toSingular(pluralName)
|
|
||||||
let params = {
|
let params = {
|
||||||
...args,
|
...args,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
limit: first,
|
limit: first,
|
||||||
offset: getOffsetWithDefault(after, -1) + 1 || undefined
|
offset: getOffsetWithDefault(after, -1) + 1 || undefined,
|
||||||
}
|
};
|
||||||
params = includeSubqueries(params, info)
|
params = includeSubqueries(params, info);
|
||||||
params = includeRelationships(params, info, info.fragments)
|
params = includeRelationships(params, info, info.fragments);
|
||||||
const formatParam = value => value.toLowerCase().replace(/ /g, '')
|
const formatParam = (value) => value.toLowerCase().replace(/ /g, '');
|
||||||
params.type = params.type.map(formatParam)
|
params.type = params.type.map(formatParam);
|
||||||
params.status = params.status.map(formatParam)
|
params.status = params.status.map(formatParam);
|
||||||
let request
|
let request;
|
||||||
if (discID) {
|
if (discID) {
|
||||||
request = loaders.lookup.load(['discid', discID, params])
|
request = loaders.lookup.load(['discid', discID, params]);
|
||||||
// If fetching releases by disc ID, they will already include the `media`
|
// If fetching releases by disc ID, they will already include the `media`
|
||||||
// and `discids` subqueries, and it is invalid to specify them.
|
// and `discids` subqueries, and it is invalid to specify them.
|
||||||
if (params.inc) {
|
if (params.inc) {
|
||||||
params.inc = params.inc.filter(value => {
|
params.inc = params.inc.filter((value) => {
|
||||||
return value !== 'media' && value !== 'discids'
|
return value !== 'media' && value !== 'discids';
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} else if (isrc) {
|
} else if (isrc) {
|
||||||
request = loaders.lookup.load(['isrc', isrc, params])
|
request = loaders.lookup.load(['isrc', isrc, params]).then((result) => {
|
||||||
|
result[pluralName].forEach((entity) => {
|
||||||
|
entity._type = singularName;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
} else if (iswc) {
|
} else if (iswc) {
|
||||||
request = loaders.lookup.load(['iswc', iswc, params])
|
request = loaders.lookup.load(['iswc', iswc, params]);
|
||||||
} else {
|
} else {
|
||||||
request = loaders.browse.load([singularName, params])
|
request = loaders.browse.load([singularName, params]);
|
||||||
}
|
}
|
||||||
return request.then(list => {
|
return request.then((list) => {
|
||||||
// Grab the list, offet, and count from the response and use them to build
|
// Grab the list, offet, and count from the response and use them to build
|
||||||
// a Relay connection object.
|
// a Relay connection object.
|
||||||
const {
|
const {
|
||||||
[pluralName]: arraySlice,
|
[pluralName]: arraySlice,
|
||||||
[`${singularName}-offset`]: sliceStart = 0,
|
[`${singularName}-offset`]: sliceStart = 0,
|
||||||
[`${singularName}-count`]: arrayLength = arraySlice.length
|
[`${singularName}-count`]: arrayLength = arraySlice.length,
|
||||||
} = list
|
} = list;
|
||||||
const meta = { sliceStart, arrayLength }
|
const meta = { sliceStart, arrayLength };
|
||||||
|
const connection = connectionFromArraySlice(
|
||||||
|
arraySlice,
|
||||||
|
{ first, after },
|
||||||
|
meta
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
|
nodes: connection.edges.map((edge) => edge.node),
|
||||||
totalCount: arrayLength,
|
totalCount: arrayLength,
|
||||||
...connectionFromArraySlice(arraySlice, { first, after }, meta)
|
...connection,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSearch (root, {
|
export function resolveSearch(
|
||||||
after,
|
root,
|
||||||
first,
|
{ after, first, query, ...args },
|
||||||
query,
|
{ loaders },
|
||||||
...args
|
info
|
||||||
}, { loaders }, info) {
|
) {
|
||||||
const pluralName = toDashed(info.fieldName)
|
const pluralName = toDashed(info.fieldName);
|
||||||
const singularName = toSingular(pluralName)
|
const singularName = toSingular(pluralName);
|
||||||
let params = {
|
let params = {
|
||||||
...args,
|
...args,
|
||||||
limit: first,
|
limit: first,
|
||||||
offset: getOffsetWithDefault(after, -1) + 1 || undefined
|
offset: getOffsetWithDefault(after, -1) + 1 || undefined,
|
||||||
}
|
};
|
||||||
params = includeSubqueries(params, info)
|
params = includeSubqueries(params, info);
|
||||||
return loaders.search.load([singularName, query, params]).then(list => {
|
return loaders.search.load([singularName, query, params]).then((list) => {
|
||||||
const {
|
const {
|
||||||
[pluralName]: arraySlice,
|
[pluralName]: arraySlice,
|
||||||
offset: sliceStart,
|
offset: sliceStart,
|
||||||
count: arrayLength
|
count: arrayLength,
|
||||||
} = list
|
} = list;
|
||||||
const meta = { sliceStart, arrayLength }
|
const meta = { sliceStart, arrayLength };
|
||||||
const connection = {
|
const connection = connectionFromArraySlice(
|
||||||
totalCount: arrayLength,
|
arraySlice,
|
||||||
...connectionFromArraySlice(arraySlice, { first, after }, meta)
|
{ first, after },
|
||||||
}
|
meta
|
||||||
|
);
|
||||||
// Move the `score` field up to the edge object and make sure it's a
|
// Move the `score` field up to the edge object and make sure it's a
|
||||||
// number (MusicBrainz returns a string).
|
// number (MusicBrainz returns a string).
|
||||||
connection.edges.forEach(edge => { edge.score = +edge.node.score })
|
const edges = connection.edges.map((edge) => ({
|
||||||
return connection
|
...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) {
|
export function resolveRelationship(rels, args, context, info) {
|
||||||
const targetType = toDashed(toSingular(info.fieldName)).replace('-', '_')
|
const targetType = toDashed(toSingular(info.fieldName)).replace('-', '_');
|
||||||
let matches = rels.filter(rel => rel['target-type'] === targetType)
|
let matches = rels.filter((rel) => rel['target-type'] === targetType);
|
||||||
// There's no way to filter these at the API level, so do it here.
|
// There's no way to filter these at the API level, so do it here.
|
||||||
if (args.direction != null) {
|
if (args.direction != null) {
|
||||||
matches = matches.filter(rel => rel.direction === args.direction)
|
matches = matches.filter((rel) => rel.direction === args.direction);
|
||||||
}
|
}
|
||||||
if (args.type != null) {
|
if (args.type != null) {
|
||||||
matches = matches.filter(rel => rel.type === args.type)
|
matches = matches.filter((rel) => rel.type === args.type);
|
||||||
}
|
}
|
||||||
if (args.typeID != null) {
|
if (args.typeID != null) {
|
||||||
matches = matches.filter(rel => rel['type-id'] === args.typeID)
|
matches = matches.filter((rel) => rel['type-id'] === args.typeID);
|
||||||
}
|
}
|
||||||
|
const connection = connectionFromArray(matches, args);
|
||||||
return {
|
return {
|
||||||
|
nodes: connection.edges.map((edge) => edge.node),
|
||||||
totalCount: matches.length,
|
totalCount: matches.length,
|
||||||
...connectionFromArray(matches, args)
|
...connection,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLinked(entity, args, context, info) {
|
export function resolveLinked(entity, args, context, info) {
|
||||||
const parentEntity = toDashed(info.parentType.name)
|
const parentEntity = toDashed(info.parentType.name);
|
||||||
args = { ...args, [parentEntity]: entity.id }
|
args = { ...args, [parentEntity]: entity.id };
|
||||||
return resolveBrowse(entity, args, context, info)
|
return resolveBrowse(entity, args, context, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -196,29 +218,34 @@ export function resolveLinked (entity, args, context, info) {
|
||||||
* for a particular field that's being requested, make another request to grab
|
* for a particular field that's being requested, make another request to grab
|
||||||
* it (after making sure it isn't already available).
|
* it (after making sure it isn't already available).
|
||||||
*/
|
*/
|
||||||
export function createSubqueryResolver ({ inc, key } = {}, handler = value => value) {
|
export function createSubqueryResolver(
|
||||||
|
{ inc, key } = {},
|
||||||
|
handler = (value) => value
|
||||||
|
) {
|
||||||
return (entity, args, { loaders }, info) => {
|
return (entity, args, { loaders }, info) => {
|
||||||
key = key || toDashed(info.fieldName)
|
key = key || toDashed(info.fieldName);
|
||||||
let promise
|
let promise;
|
||||||
if (key in entity) {
|
if (key in entity) {
|
||||||
promise = Promise.resolve(entity)
|
promise = Promise.resolve(entity);
|
||||||
} else {
|
} else {
|
||||||
const { _type: entityType, id } = entity
|
const { _type: entityType, id } = entity;
|
||||||
const params = { inc: [inc || key] }
|
const params = { inc: [inc || key] };
|
||||||
promise = loaders.lookup.load([entityType, id, params])
|
promise = loaders.lookup.load([entityType, id, params]);
|
||||||
}
|
|
||||||
return promise.then(entity => handler(entity[key], args))
|
|
||||||
}
|
}
|
||||||
|
return promise.then((entity) => handler(entity[key], args));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDiscReleases(disc, args, context, info) {
|
export function resolveDiscReleases(disc, args, context, info) {
|
||||||
const { releases } = disc
|
const { releases } = disc;
|
||||||
if (releases != null) {
|
if (releases != null) {
|
||||||
|
const connection = connectionFromArray(releases, args);
|
||||||
return {
|
return {
|
||||||
|
nodes: connection.edges.map((edge) => edge.node),
|
||||||
totalCount: releases.length,
|
totalCount: releases.length,
|
||||||
...connectionFromArray(releases, args)
|
...connection,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
args = { ...args, discID: disc.id };
|
||||||
args = { ...args, discID: disc.id }
|
return resolveBrowse(disc, args, context, info);
|
||||||
return resolveBrowse(disc, args, context, info)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,62 @@
|
||||||
import { GraphQLSchema, GraphQLObjectType } from 'graphql'
|
import createDebug from 'debug';
|
||||||
import { lookup, browse, search } from './queries'
|
import GraphQL from 'graphql';
|
||||||
import { nodeField } from './types/node'
|
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({
|
query: new GraphQLObjectType({
|
||||||
name: 'Query',
|
name: 'Query',
|
||||||
description: `The query root, from which multiple types of MusicBrainz
|
description: `The query root, from which multiple types of MusicBrainz
|
||||||
|
|
@ -11,7 +65,7 @@ requests can be made.`,
|
||||||
lookup,
|
lookup,
|
||||||
browse,
|
browse,
|
||||||
search,
|
search,
|
||||||
node: nodeField
|
node: nodeField,
|
||||||
})
|
}),
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
|
||||||
17
src/tag.js
Normal file
17
src/tag.js
Normal 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]);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import { Locale } from './scalars.js';
|
||||||
GraphQLBoolean
|
import { name, sortName, fieldWithID } from './helpers.js';
|
||||||
} from 'graphql/type'
|
import { createSubqueryResolver } from '../resolvers.js';
|
||||||
import { Locale } from './scalars'
|
|
||||||
import { name, sortName, fieldWithID } from './helpers'
|
|
||||||
|
|
||||||
export default new GraphQLObjectType({
|
const { GraphQLObjectType, GraphQLBoolean, GraphQLList } = GraphQL;
|
||||||
|
|
||||||
|
export const Alias = new GraphQLObjectType({
|
||||||
name: 'Alias',
|
name: 'Alias',
|
||||||
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are variant names
|
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are variant names
|
||||||
that are mostly used as search help: if a search matches an entity’s alias, the
|
that are mostly used as search help: if a search matches an entity’s alias, the
|
||||||
|
|
@ -13,22 +13,29 @@ entity will be given as a result – even if the actual name wouldn’t be.`,
|
||||||
fields: () => ({
|
fields: () => ({
|
||||||
name: {
|
name: {
|
||||||
...name,
|
...name,
|
||||||
description: 'The aliased name of the entity.'
|
description: 'The aliased name of the entity.',
|
||||||
},
|
},
|
||||||
sortName,
|
sortName,
|
||||||
locale: {
|
locale: {
|
||||||
type: Locale,
|
type: Locale,
|
||||||
description: `The locale (language and/or country) in which the alias is
|
description: `The locale (language and/or country) in which the alias is
|
||||||
used.`
|
used.`,
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
description: `Whether this is the main alias for the entity in the
|
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', {
|
...fieldWithID('type', {
|
||||||
description: `The type or purpose of the alias – whether it is a variant,
|
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(),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
name,
|
name,
|
||||||
sortName,
|
sortName,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
fieldWithID,
|
||||||
artists,
|
connectionWithExtras,
|
||||||
events,
|
linkedQuery,
|
||||||
labels,
|
} from './helpers.js';
|
||||||
places,
|
import { events } from './event.js';
|
||||||
releases,
|
import { aliases } from './alias.js';
|
||||||
relationships,
|
import { artists } from './artist.js';
|
||||||
collections,
|
import { labels } from './label.js';
|
||||||
tags,
|
import { places } from './place.js';
|
||||||
connectionWithExtras
|
import { releases } from './release.js';
|
||||||
} from './helpers'
|
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',
|
name: 'Area',
|
||||||
description: `[Areas](https://musicbrainz.org/doc/Area) are geographic regions
|
description: `[Areas](https://musicbrainz.org/doc/Area) are geographic regions
|
||||||
or settlements (countries, cities, or the like).`,
|
or settlements (countries, cities, or the like).`,
|
||||||
|
|
@ -35,8 +39,23 @@ or settlements (countries, cities, or the like).`,
|
||||||
type: new GraphQLList(GraphQLString),
|
type: new GraphQLList(GraphQLString),
|
||||||
description: `[ISO 3166 codes](https://en.wikipedia.org/wiki/ISO_3166) are
|
description: `[ISO 3166 codes](https://en.wikipedia.org/wiki/ISO_3166) are
|
||||||
the codes assigned by ISO to countries and subdivisions.`,
|
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,
|
artists,
|
||||||
events,
|
events,
|
||||||
labels,
|
labels,
|
||||||
|
|
@ -44,9 +63,10 @@ the codes assigned by ISO to countries and subdivisions.`,
|
||||||
releases,
|
releases,
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const AreaConnection = connectionWithExtras(Area)
|
export const AreaConnection = connectionWithExtras(Area);
|
||||||
export default Area
|
|
||||||
|
export const areas = linkedQuery(AreaConnection);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Artist from './artist'
|
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',
|
name: 'ArtistCredit',
|
||||||
description: `[Artist credits](https://musicbrainz.org/doc/Artist_Credits)
|
description: `[Artist credits](https://musicbrainz.org/doc/Artist_Credits)
|
||||||
indicate who is the main credited artist (or artists) for releases, release
|
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
|
description: `The entity representing the artist referenced in the
|
||||||
credits.`,
|
credits.`,
|
||||||
resolve: (source) => {
|
resolve: (source) => {
|
||||||
const { artist } = source
|
const { artist } = source;
|
||||||
if (artist) {
|
if (artist) {
|
||||||
artist._type = 'artist'
|
artist._type = 'artist';
|
||||||
}
|
|
||||||
return artist
|
|
||||||
}
|
}
|
||||||
|
return artist;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `The name of the artist as credited in the specific release,
|
description: `The name of the artist as credited in the specific release,
|
||||||
track, etc.`
|
track, etc.`,
|
||||||
},
|
},
|
||||||
joinPhrase: {
|
joinPhrase: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `Join phrases might include words and/or punctuation to
|
description: `Join phrases might include words and/or punctuation to
|
||||||
separate artist names as they appear on the release, track, etc.`,
|
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.`,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import Area from './area'
|
import { Area } from './area.js';
|
||||||
import { IPI, ISNI } from './scalars'
|
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 {
|
import {
|
||||||
resolveWithFallback,
|
resolveWithFallback,
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
|
|
@ -11,20 +21,13 @@ import {
|
||||||
name,
|
name,
|
||||||
sortName,
|
sortName,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
connectionWithExtras,
|
||||||
lifeSpan,
|
linkedQuery,
|
||||||
recordings,
|
} from './helpers.js';
|
||||||
releases,
|
|
||||||
releaseGroups,
|
|
||||||
works,
|
|
||||||
relationships,
|
|
||||||
collections,
|
|
||||||
rating,
|
|
||||||
tags,
|
|
||||||
connectionWithExtras
|
|
||||||
} from './helpers'
|
|
||||||
|
|
||||||
const Artist = new GraphQLObjectType({
|
const { GraphQLObjectType, GraphQLString, GraphQLList } = GraphQL;
|
||||||
|
|
||||||
|
export const Artist = new GraphQLObjectType({
|
||||||
name: 'Artist',
|
name: 'Artist',
|
||||||
description: `An [artist](https://musicbrainz.org/doc/Artist) is generally a
|
description: `An [artist](https://musicbrainz.org/doc/Artist) is generally a
|
||||||
musician, group of musicians, or other music professional (like a producer or
|
musician, group of musicians, or other music professional (like a producer or
|
||||||
|
|
@ -42,42 +45,42 @@ even a fictional character.`,
|
||||||
country: {
|
country: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `The country with which an artist is primarily identified. It
|
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: {
|
area: {
|
||||||
type: Area,
|
type: Area,
|
||||||
description: `The area with which an artist is primarily identified. It
|
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: {
|
beginArea: {
|
||||||
type: Area,
|
type: Area,
|
||||||
description: `The area in which an artist began their career (or where
|
description: `The area in which an artist began their career (or where
|
||||||
they were born, if the artist is a person).`,
|
they were born, if the artist is a person).`,
|
||||||
resolve: resolveWithFallback(['begin-area', 'begin_area'])
|
resolve: resolveWithFallback(['begin-area', 'begin_area']),
|
||||||
},
|
},
|
||||||
endArea: {
|
endArea: {
|
||||||
type: Area,
|
type: Area,
|
||||||
description: `The area in which an artist ended their career (or where
|
description: `The area in which an artist ended their career (or where
|
||||||
they died, if the artist is a person).`,
|
they died, if the artist is a person).`,
|
||||||
resolve: resolveWithFallback(['end-area', 'end_area'])
|
resolve: resolveWithFallback(['end-area', 'end_area']),
|
||||||
},
|
},
|
||||||
lifeSpan,
|
lifeSpan,
|
||||||
...fieldWithID('gender', {
|
...fieldWithID('gender', {
|
||||||
description: `Whether a person or character identifies as male, female, or
|
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', {
|
...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: {
|
ipis: {
|
||||||
type: new GraphQLList(IPI),
|
type: new GraphQLList(IPI),
|
||||||
description: `List of [Interested Parties Information](https://musicbrainz.org/doc/IPI)
|
description: `List of [Interested Parties Information](https://musicbrainz.org/doc/IPI)
|
||||||
(IPI) codes for the artist.`
|
(IPI) codes for the artist.`,
|
||||||
},
|
},
|
||||||
isnis: {
|
isnis: {
|
||||||
type: new GraphQLList(ISNI),
|
type: new GraphQLList(ISNI),
|
||||||
description: `List of [International Standard Name Identifier](https://musicbrainz.org/doc/ISNI)
|
description: `List of [International Standard Name Identifier](https://musicbrainz.org/doc/ISNI)
|
||||||
(ISNI) codes for the artist.`
|
(ISNI) codes for the artist.`,
|
||||||
},
|
},
|
||||||
recordings,
|
recordings,
|
||||||
releases,
|
releases,
|
||||||
|
|
@ -86,9 +89,10 @@ neither. Groups do not have genders.`
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
rating,
|
rating,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ArtistConnection = connectionWithExtras(Artist)
|
export const ArtistConnection = connectionWithExtras(Artist);
|
||||||
export default Artist
|
|
||||||
|
export const artists = linkedQuery(ArtistConnection);
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,31 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import { Node } from './node.js';
|
||||||
GraphQLNonNull,
|
import { Entity } from './entity.js';
|
||||||
GraphQLString
|
|
||||||
} from 'graphql/type'
|
|
||||||
import Node from './node'
|
|
||||||
import Entity from './entity'
|
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
name,
|
name,
|
||||||
areas,
|
|
||||||
artists,
|
|
||||||
events,
|
|
||||||
instruments,
|
|
||||||
labels,
|
|
||||||
places,
|
|
||||||
recordings,
|
|
||||||
releases,
|
|
||||||
releaseGroups,
|
|
||||||
series,
|
|
||||||
works,
|
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
resolveHyphenated,
|
resolveHyphenated,
|
||||||
createCollectionField,
|
createCollectionField,
|
||||||
connectionWithExtras
|
connectionWithExtras,
|
||||||
} from './helpers'
|
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 Collection = new GraphQLObjectType({
|
const { GraphQLObjectType, GraphQLNonNull, GraphQLString } = GraphQL;
|
||||||
|
|
||||||
|
export const Collection = new GraphQLObjectType({
|
||||||
name: 'Collection',
|
name: 'Collection',
|
||||||
description: `[Collections](https://musicbrainz.org/doc/Collections) are
|
description: `[Collections](https://musicbrainz.org/doc/Collections) are
|
||||||
lists of entities that users can create.`,
|
lists of entities that users can create.`,
|
||||||
|
|
@ -37,15 +36,15 @@ lists of entities that users can create.`,
|
||||||
name,
|
name,
|
||||||
editor: {
|
editor: {
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
type: new GraphQLNonNull(GraphQLString),
|
||||||
description: 'The username of the editor who created the collection.'
|
description: 'The username of the editor who created the collection.',
|
||||||
},
|
},
|
||||||
entityType: {
|
entityType: {
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
type: new GraphQLNonNull(GraphQLString),
|
||||||
description: 'The type of entity listed in the collection.',
|
description: 'The type of entity listed in the collection.',
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
...fieldWithID('type', {
|
...fieldWithID('type', {
|
||||||
description: 'The type of collection.'
|
description: 'The type of collection.',
|
||||||
}),
|
}),
|
||||||
areas: createCollectionField(areas),
|
areas: createCollectionField(areas),
|
||||||
artists: createCollectionField(artists),
|
artists: createCollectionField(artists),
|
||||||
|
|
@ -57,9 +56,12 @@ lists of entities that users can create.`,
|
||||||
releases: createCollectionField(releases),
|
releases: createCollectionField(releases),
|
||||||
releaseGroups: createCollectionField(releaseGroups),
|
releaseGroups: createCollectionField(releaseGroups),
|
||||||
series: createCollectionField(series),
|
series: createCollectionField(series),
|
||||||
works: createCollectionField(works)
|
works: createCollectionField(works),
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const CollectionConnection = connectionWithExtras(Collection)
|
export const CollectionConnection = connectionWithExtras(Collection);
|
||||||
export default Collection
|
|
||||||
|
export const collections = linkedQuery(CollectionConnection, {
|
||||||
|
description: 'A list of collections containing this entity.',
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import {
|
|
||||||
GraphQLObjectType,
|
|
||||||
GraphQLNonNull,
|
|
||||||
GraphQLList,
|
|
||||||
GraphQLBoolean,
|
|
||||||
GraphQLString,
|
|
||||||
GraphQLInt
|
|
||||||
} from 'graphql/type'
|
|
||||||
import { URLString } from './scalars'
|
|
||||||
|
|
||||||
export const CoverArtImageThumbnails = new GraphQLObjectType({
|
|
||||||
name: 'CoverArtImageThumbnails',
|
|
||||||
description: `URLs for thumbnails of different sizes for a particular piece of
|
|
||||||
cover art.`,
|
|
||||||
fields: () => ({
|
|
||||||
small: {
|
|
||||||
type: URLString,
|
|
||||||
description: `The URL of a small version of the cover art, where the
|
|
||||||
maximum dimension is 250px.`
|
|
||||||
},
|
|
||||||
large: {
|
|
||||||
type: URLString,
|
|
||||||
description: `The URL of a large version of the cover art, where the
|
|
||||||
maximum dimension is 500px.`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export default new GraphQLObjectType({
|
|
||||||
name: 'CoverArtImage',
|
|
||||||
description: 'An individual piece of album artwork from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).',
|
|
||||||
fields: () => ({
|
|
||||||
fileID: {
|
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
|
||||||
description: 'The Internet Archive’s internal file ID for the image.',
|
|
||||||
resolve: image => image.id
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
type: new GraphQLNonNull(URLString),
|
|
||||||
description: 'The URL at which the image can be found.'
|
|
||||||
},
|
|
||||||
thumbnails: {
|
|
||||||
type: CoverArtImageThumbnails,
|
|
||||||
description: 'A set of thumbnails for the image.'
|
|
||||||
},
|
|
||||||
front: {
|
|
||||||
type: new GraphQLNonNull(GraphQLBoolean),
|
|
||||||
description: 'Whether this image depicts the “main front” of the release.'
|
|
||||||
},
|
|
||||||
back: {
|
|
||||||
type: new GraphQLNonNull(GraphQLBoolean),
|
|
||||||
description: 'Whether this image depicts the “main back” of the release.'
|
|
||||||
},
|
|
||||||
types: {
|
|
||||||
type: new GraphQLList(GraphQLString),
|
|
||||||
description: `A list of [image types](https://musicbrainz.org/doc/Cover_Art/Types)
|
|
||||||
describing what part(s) of the release the image includes.`
|
|
||||||
},
|
|
||||||
edit: {
|
|
||||||
type: GraphQLInt,
|
|
||||||
description: 'The MusicBrainz edit ID.'
|
|
||||||
},
|
|
||||||
approved: {
|
|
||||||
type: GraphQLBoolean,
|
|
||||||
description: 'Whether the image was approved by the MusicBrainz edit system.'
|
|
||||||
},
|
|
||||||
comment: {
|
|
||||||
type: GraphQLString,
|
|
||||||
description: 'A free-text comment left for the image.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
import {
|
|
||||||
GraphQLObjectType,
|
|
||||||
GraphQLList,
|
|
||||||
GraphQLNonNull,
|
|
||||||
GraphQLBoolean,
|
|
||||||
GraphQLInt
|
|
||||||
} from 'graphql/type'
|
|
||||||
import CoverArtImage from './cover-art-image'
|
|
||||||
import { CoverArtImageSize } from './enums'
|
|
||||||
import Release from './release'
|
|
||||||
import { URLString } from './scalars'
|
|
||||||
import { resolveLookup } from '../resolvers'
|
|
||||||
import { getFields } from '../util'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a resolver that will call `resolveFn` only if the requested field on
|
|
||||||
* the object is null or not present.
|
|
||||||
*/
|
|
||||||
function createFallbackResolver (resolveFn) {
|
|
||||||
return function resolve (coverArt, args, context, info) {
|
|
||||||
const value = coverArt[info.fieldName]
|
|
||||||
if (value == null) {
|
|
||||||
return resolveFn(coverArt, args, context, info)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveImage (coverArt, { size }, { loaders }, info) {
|
|
||||||
if (size === 'FULL') {
|
|
||||||
size = null
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (coverArt[field] !== false) {
|
|
||||||
const {
|
|
||||||
_parentType: entityType = 'release',
|
|
||||||
_parentID: id = coverArt._release
|
|
||||||
} = coverArt
|
|
||||||
return loaders.coverArtURL.load([entityType, id, field, size])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const size = {
|
|
||||||
type: CoverArtImageSize,
|
|
||||||
description: `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.`,
|
|
||||||
defaultValue: null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get around both the circular dependency between the release and cover art
|
|
||||||
* types, and not have to define an identical `release` field twice on
|
|
||||||
* `ReleaseCoverArt` and `ReleaseGroupCoverArt`.
|
|
||||||
*/
|
|
||||||
function createReleaseField () {
|
|
||||||
return {
|
|
||||||
type: new GraphQLNonNull(Release),
|
|
||||||
description: 'The particular release shown in the returned cover art.',
|
|
||||||
resolve: (coverArt, args, context, info) => {
|
|
||||||
const id = coverArt._release
|
|
||||||
const fields = Object.keys(getFields(info))
|
|
||||||
if (fields.length > 1 || fields[0] !== 'mbid') {
|
|
||||||
return resolveLookup(coverArt, { mbid: id }, context, info)
|
|
||||||
}
|
|
||||||
return { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This type combines two sets of data from different places. One is a *summary*
|
|
||||||
// of the images available at the Cover Art Archive, found in the `cover-art-archive`
|
|
||||||
// field on releases. The other is the actual list of images with their metadata,
|
|
||||||
// fetched from the Cover Art Archive itself rather than MusicBrainz. Depending
|
|
||||||
// on what fields are requested, we may only need to fetch one or the other, or
|
|
||||||
// both. Much of the summary data can be reconstructed if we already fetched the
|
|
||||||
// full image list, for example.
|
|
||||||
export const ReleaseCoverArt = new GraphQLObjectType({
|
|
||||||
name: 'ReleaseCoverArt',
|
|
||||||
description: `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.`,
|
|
||||||
fields: () => ({
|
|
||||||
front: {
|
|
||||||
type: URLString,
|
|
||||||
description: `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.`,
|
|
||||||
args: { size },
|
|
||||||
resolve: resolveImage
|
|
||||||
},
|
|
||||||
back: {
|
|
||||||
type: URLString,
|
|
||||||
description: `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.`,
|
|
||||||
args: { size },
|
|
||||||
resolve: resolveImage
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
type: new GraphQLList(CoverArtImage),
|
|
||||||
description: `A list of images depicting the different sides and surfaces
|
|
||||||
of a release’s media and packaging.`,
|
|
||||||
resolve: createFallbackResolver((coverArt, args, { loaders }) => {
|
|
||||||
if (coverArt.count === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return loaders.coverArt.load(['release', coverArt._release])
|
|
||||||
.then(coverArt => coverArt.images)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
artwork: {
|
|
||||||
type: new GraphQLNonNull(GraphQLBoolean),
|
|
||||||
description: 'Whether there is artwork present for this release.',
|
|
||||||
resolve: createFallbackResolver(coverArt => coverArt.images.length > 0)
|
|
||||||
},
|
|
||||||
darkened: {
|
|
||||||
type: new GraphQLNonNull(GraphQLBoolean),
|
|
||||||
description: `Whether the Cover Art Archive has received a take-down
|
|
||||||
request for this release’s artwork, disallowing new uploads.`,
|
|
||||||
resolve: createFallbackResolver((coverArt, args, { loaders }) => {
|
|
||||||
return loaders.lookup.load(['release', coverArt._release])
|
|
||||||
.then(release => release['cover-art-archive'].darkened)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
|
||||||
description: 'The number of artwork images present for this release.',
|
|
||||||
resolve: createFallbackResolver(coverArt => coverArt.images.length)
|
|
||||||
},
|
|
||||||
release: createReleaseField()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ReleaseGroupCoverArt = new GraphQLObjectType({
|
|
||||||
name: 'ReleaseGroupCoverArt',
|
|
||||||
description: `An object containing the cover art for a release group obtained
|
|
||||||
from the [Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive). For
|
|
||||||
release groups, just the front cover of a particular release will be selected.`,
|
|
||||||
fields: () => ({
|
|
||||||
front: {
|
|
||||||
type: URLString,
|
|
||||||
description: `The URL of an image depicting the album cover or “main
|
|
||||||
front” of a release in the release group, 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).`,
|
|
||||||
args: { size },
|
|
||||||
resolve: resolveImage
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
type: new GraphQLList(CoverArtImage),
|
|
||||||
description: `A list of images returned by the [Cover Art
|
|
||||||
Archive](https://musicbrainz.org/doc/Cover_Art_Archive) for a release group. A
|
|
||||||
particular release’s front image will be included in the list, and likely no
|
|
||||||
others, even if other images are available.`
|
|
||||||
},
|
|
||||||
artwork: {
|
|
||||||
type: new GraphQLNonNull(GraphQLBoolean),
|
|
||||||
description: 'Whether there is artwork present for this release group.',
|
|
||||||
resolve: createFallbackResolver(coverArt => coverArt.images.length > 0)
|
|
||||||
},
|
|
||||||
release: createReleaseField()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import GraphQLRelay from 'graphql-relay';
|
||||||
GraphQLNonNull,
|
import { Node } from './node.js';
|
||||||
GraphQLList,
|
import { DiscID } from './scalars.js';
|
||||||
GraphQLInt
|
import { ReleaseConnection } from './release.js';
|
||||||
} from 'graphql/type'
|
import { resolveDiscReleases } from '../resolvers.js';
|
||||||
import { forwardConnectionArgs } from 'graphql-relay'
|
import { id, resolveHyphenated } from './helpers.js';
|
||||||
import Node from './node'
|
|
||||||
import { DiscID } from './scalars'
|
|
||||||
import { ReleaseConnection } from './release'
|
|
||||||
import { resolveDiscReleases } from '../resolvers'
|
|
||||||
import { id, resolveHyphenated } from './helpers'
|
|
||||||
|
|
||||||
export default new GraphQLObjectType({
|
const { GraphQLObjectType, GraphQLNonNull, GraphQLList, GraphQLInt } = GraphQL;
|
||||||
|
const { forwardConnectionArgs } = GraphQLRelay;
|
||||||
|
|
||||||
|
export const Disc = new GraphQLObjectType({
|
||||||
name: 'Disc',
|
name: 'Disc',
|
||||||
description: `Information about the physical CD and releases associated with a
|
description: `Information about the physical CD and releases associated with a
|
||||||
particular [disc ID](https://musicbrainz.org/doc/Disc_ID).`,
|
particular [disc ID](https://musicbrainz.org/doc/Disc_ID).`,
|
||||||
|
|
@ -21,26 +19,26 @@ particular [disc ID](https://musicbrainz.org/doc/Disc_ID).`,
|
||||||
discID: {
|
discID: {
|
||||||
type: new GraphQLNonNull(DiscID),
|
type: new GraphQLNonNull(DiscID),
|
||||||
description: `The [disc ID](https://musicbrainz.org/doc/Disc_ID) of this disc.`,
|
description: `The [disc ID](https://musicbrainz.org/doc/Disc_ID) of this disc.`,
|
||||||
resolve: disc => disc.id
|
resolve: (disc) => disc.id,
|
||||||
},
|
},
|
||||||
offsetCount: {
|
offsetCount: {
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
description: 'The number of offsets (tracks) on the disc.',
|
description: 'The number of offsets (tracks) on the disc.',
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
offsets: {
|
offsets: {
|
||||||
type: new GraphQLList(GraphQLInt),
|
type: new GraphQLList(GraphQLInt),
|
||||||
description: 'The sector offset of each track on the disc.'
|
description: 'The sector offset of each track on the disc.',
|
||||||
},
|
},
|
||||||
sectors: {
|
sectors: {
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
description: 'The sector offset of the lead-out (the end of the disc).'
|
description: 'The sector offset of the lead-out (the end of the disc).',
|
||||||
},
|
},
|
||||||
releases: {
|
releases: {
|
||||||
type: ReleaseConnection,
|
type: ReleaseConnection,
|
||||||
description: 'The list of releases linked to this disc ID.',
|
description: 'The list of releases linked to this disc ID.',
|
||||||
args: forwardConnectionArgs,
|
args: forwardConnectionArgs,
|
||||||
resolve: resolveDiscReleases
|
resolve: resolveDiscReleases,
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
import { GraphQLInterfaceType } from 'graphql'
|
import GraphQL from 'graphql';
|
||||||
import { mbid, connectionWithExtras } from './helpers'
|
import { mbid, connectionWithExtras, resolveType } from './helpers.js';
|
||||||
|
|
||||||
const Entity = new GraphQLInterfaceType({
|
const { GraphQLInterfaceType } = GraphQL;
|
||||||
|
|
||||||
|
export const Entity = new GraphQLInterfaceType({
|
||||||
name: 'Entity',
|
name: 'Entity',
|
||||||
description: 'An entity in the MusicBrainz schema.',
|
description: 'An entity in the MusicBrainz schema.',
|
||||||
resolveType (value) {
|
resolveType,
|
||||||
if (value._type && require.resolve(`./${value._type}`)) {
|
fields: () => ({ mbid }),
|
||||||
return require(`./${value._type}`).default
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
fields: () => ({ mbid })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const EntityConnection = connectionWithExtras(Entity)
|
export const EntityConnection = connectionWithExtras(Entity);
|
||||||
export default Entity
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { GraphQLEnumType } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
|
|
||||||
|
const { GraphQLEnumType } = GraphQL;
|
||||||
|
|
||||||
export const ArtistType = new GraphQLEnumType({
|
export const ArtistType = new GraphQLEnumType({
|
||||||
name: 'ArtistType',
|
name: 'ArtistType',
|
||||||
|
|
@ -8,36 +10,37 @@ etc.`,
|
||||||
PERSON: {
|
PERSON: {
|
||||||
name: 'Person',
|
name: 'Person',
|
||||||
description: 'This indicates an individual person.',
|
description: 'This indicates an individual person.',
|
||||||
value: 'Person'
|
value: 'Person',
|
||||||
},
|
},
|
||||||
GROUP: {
|
GROUP: {
|
||||||
name: 'Group',
|
name: 'Group',
|
||||||
description: `This indicates a group of people that may or may not have a
|
description: `This indicates a group of people that may or may not have a
|
||||||
distinctive name.`,
|
distinctive name.`,
|
||||||
value: 'Group'
|
value: 'Group',
|
||||||
},
|
},
|
||||||
ORCHESTRA: {
|
ORCHESTRA: {
|
||||||
name: 'Orchestra',
|
name: 'Orchestra',
|
||||||
description: 'This indicates an orchestra (a large instrumental ensemble).',
|
description:
|
||||||
value: 'Orchestra'
|
'This indicates an orchestra (a large instrumental ensemble).',
|
||||||
|
value: 'Orchestra',
|
||||||
},
|
},
|
||||||
CHOIR: {
|
CHOIR: {
|
||||||
name: 'Choir',
|
name: 'Choir',
|
||||||
description: 'This indicates a choir/chorus (a large vocal ensemble).',
|
description: 'This indicates a choir/chorus (a large vocal ensemble).',
|
||||||
value: 'Choir'
|
value: 'Choir',
|
||||||
},
|
},
|
||||||
CHARACTER: {
|
CHARACTER: {
|
||||||
name: 'Character',
|
name: 'Character',
|
||||||
description: 'This indicates an individual fictional character.',
|
description: 'This indicates an individual fictional character.',
|
||||||
value: 'Character'
|
value: 'Character',
|
||||||
},
|
},
|
||||||
OTHER: {
|
OTHER: {
|
||||||
name: 'Other',
|
name: 'Other',
|
||||||
description: 'An artist which does not fit into the other categories.',
|
description: 'An artist which does not fit into the other categories.',
|
||||||
value: 'Other'
|
value: 'Other',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const CoverArtImageSize = new GraphQLEnumType({
|
export const CoverArtImageSize = new GraphQLEnumType({
|
||||||
name: 'CoverArtImageSize',
|
name: 'CoverArtImageSize',
|
||||||
|
|
@ -47,20 +50,20 @@ Archive](https://musicbrainz.org/doc/Cover_Art_Archive).`,
|
||||||
SMALL: {
|
SMALL: {
|
||||||
name: 'Small',
|
name: 'Small',
|
||||||
description: 'A maximum dimension of 250px.',
|
description: 'A maximum dimension of 250px.',
|
||||||
value: 250
|
value: 250,
|
||||||
},
|
},
|
||||||
LARGE: {
|
LARGE: {
|
||||||
name: 'Large',
|
name: 'Large',
|
||||||
description: 'A maximum dimension of 500px.',
|
description: 'A maximum dimension of 500px.',
|
||||||
value: 500
|
value: 500,
|
||||||
},
|
},
|
||||||
FULL: {
|
FULL: {
|
||||||
name: 'Full',
|
name: 'Full',
|
||||||
description: 'The image’s original dimensions, with no maximum.',
|
description: 'The image’s original dimensions, with no maximum.',
|
||||||
value: null
|
value: null,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ReleaseStatus = new GraphQLEnumType({
|
export const ReleaseStatus = new GraphQLEnumType({
|
||||||
name: 'ReleaseStatus',
|
name: 'ReleaseStatus',
|
||||||
|
|
@ -71,29 +74,29 @@ bootleg, etc.`,
|
||||||
name: 'Official',
|
name: 'Official',
|
||||||
description: `Any release officially sanctioned by the artist and/or their
|
description: `Any release officially sanctioned by the artist and/or their
|
||||||
record company. (Most releases will fit into this category.)`,
|
record company. (Most releases will fit into this category.)`,
|
||||||
value: 'Official'
|
value: 'Official',
|
||||||
},
|
},
|
||||||
PROMOTION: {
|
PROMOTION: {
|
||||||
name: 'Promotion',
|
name: 'Promotion',
|
||||||
description: `A giveaway release or a release intended to promote an
|
description: `A giveaway release or a release intended to promote an
|
||||||
upcoming official release, e.g. prerelease albums or releases included with a
|
upcoming official release, e.g. prerelease albums or releases included with a
|
||||||
magazine.`,
|
magazine.`,
|
||||||
value: 'Promotion'
|
value: 'Promotion',
|
||||||
},
|
},
|
||||||
BOOTLEG: {
|
BOOTLEG: {
|
||||||
name: 'Bootleg',
|
name: 'Bootleg',
|
||||||
description: `An unofficial/underground release that was not sanctioned by
|
description: `An unofficial/underground release that was not sanctioned by
|
||||||
the artist and/or the record company.`,
|
the artist and/or the record company.`,
|
||||||
value: 'Bootleg'
|
value: 'Bootleg',
|
||||||
},
|
},
|
||||||
PSEUDORELEASE: {
|
PSEUDORELEASE: {
|
||||||
name: 'Pseudo-Release',
|
name: 'Pseudo-Release',
|
||||||
description: `A pseudo-release is a duplicate release for
|
description: `A pseudo-release is a duplicate release for
|
||||||
translation/transliteration purposes.`,
|
translation/transliteration purposes.`,
|
||||||
value: 'Pseudo-Release'
|
value: 'Pseudo-Release',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ReleaseGroupType = new GraphQLEnumType({
|
export const ReleaseGroupType = new GraphQLEnumType({
|
||||||
name: 'ReleaseGroupType',
|
name: 'ReleaseGroupType',
|
||||||
|
|
@ -106,14 +109,14 @@ etc.`,
|
||||||
release, generally consists of previously unreleased material (unless this type
|
release, generally consists of previously unreleased material (unless this type
|
||||||
is combined with secondary types which change that, such as “Compilation”). This
|
is combined with secondary types which change that, such as “Compilation”). This
|
||||||
includes album re-issues, with or without bonus tracks.`,
|
includes album re-issues, with or without bonus tracks.`,
|
||||||
value: 'Album'
|
value: 'Album',
|
||||||
},
|
},
|
||||||
SINGLE: {
|
SINGLE: {
|
||||||
name: 'Single',
|
name: 'Single',
|
||||||
description: `A single typically has one main song and possibly a handful
|
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
|
of additional tracks or remixes of the main track. A single is usually named
|
||||||
after its main song.`,
|
after its main song.`,
|
||||||
value: 'Single'
|
value: 'Single',
|
||||||
},
|
},
|
||||||
EP: {
|
EP: {
|
||||||
name: 'EP',
|
name: 'EP',
|
||||||
|
|
@ -123,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 don’t come from a previously issued
|
to the EP, in other words the tracks don’t come from a previously issued
|
||||||
release. EP is fairly difficult to define; usually it should only be assumed
|
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.`,
|
that a release is an EP if the artist defines it as such.`,
|
||||||
value: 'EP'
|
value: 'EP',
|
||||||
},
|
},
|
||||||
OTHER: {
|
OTHER: {
|
||||||
name: 'Other',
|
name: 'Other',
|
||||||
description: 'Any release that does not fit any of the other categories.',
|
description: 'Any release that does not fit any of the other categories.',
|
||||||
value: 'Other'
|
value: 'Other',
|
||||||
},
|
},
|
||||||
BROADCAST: {
|
BROADCAST: {
|
||||||
name: 'Broadcast',
|
name: 'Broadcast',
|
||||||
description: `An episodic release that was originally broadcast via radio,
|
description: `An episodic release that was originally broadcast via radio,
|
||||||
television, or the Internet, including podcasts.`,
|
television, or the Internet, including podcasts.`,
|
||||||
value: 'Broadcast'
|
value: 'Broadcast',
|
||||||
},
|
},
|
||||||
COMPILATION: {
|
COMPILATION: {
|
||||||
name: 'Compilation',
|
name: 'Compilation',
|
||||||
description: `A compilation is a collection of previously released tracks
|
description: `A compilation is a collection of previously released tracks
|
||||||
by one or more artists.`,
|
by one or more artists.`,
|
||||||
value: 'Compilation'
|
value: 'Compilation',
|
||||||
},
|
},
|
||||||
SOUNDTRACK: {
|
SOUNDTRACK: {
|
||||||
name: 'Soundtrack',
|
name: 'Soundtrack',
|
||||||
description: `A soundtrack is the musical score to a movie, TV series,
|
description: `A soundtrack is the musical score to a movie, TV series,
|
||||||
stage show, computer game, etc.`,
|
stage show, computer game, etc.`,
|
||||||
value: 'Soundtrack'
|
value: 'Soundtrack',
|
||||||
},
|
},
|
||||||
SPOKENWORD: {
|
SPOKENWORD: {
|
||||||
name: 'Spoken Word',
|
name: 'Spoken Word',
|
||||||
description: 'A non-music spoken word release.',
|
description: 'A non-music spoken word release.',
|
||||||
value: 'Spoken Word'
|
value: 'Spoken Word',
|
||||||
},
|
},
|
||||||
INTERVIEW: {
|
INTERVIEW: {
|
||||||
name: 'Interview',
|
name: 'Interview',
|
||||||
description: `An interview release contains an interview, generally with
|
description: `An interview release contains an interview, generally with
|
||||||
an artist.`,
|
an artist.`,
|
||||||
value: 'Interview'
|
value: 'Interview',
|
||||||
},
|
},
|
||||||
AUDIOBOOK: {
|
AUDIOBOOK: {
|
||||||
name: 'Audiobook',
|
name: 'Audiobook',
|
||||||
description: 'An audiobook is a book read by a narrator without music.',
|
description: 'An audiobook is a book read by a narrator without music.',
|
||||||
value: 'Audiobook'
|
value: 'Audiobook',
|
||||||
},
|
},
|
||||||
LIVE: {
|
LIVE: {
|
||||||
name: 'Live',
|
name: 'Live',
|
||||||
description: 'A release that was recorded live.',
|
description: 'A release that was recorded live.',
|
||||||
value: 'Live'
|
value: 'Live',
|
||||||
},
|
},
|
||||||
REMIX: {
|
REMIX: {
|
||||||
name: 'Remix',
|
name: 'Remix',
|
||||||
description: `A release that was (re)mixed from previously released
|
description: `A release that was (re)mixed from previously released
|
||||||
material.`,
|
material.`,
|
||||||
value: 'Remix'
|
value: 'Remix',
|
||||||
},
|
},
|
||||||
DJMIX: {
|
DJMIX: {
|
||||||
name: 'DJ-mix',
|
name: 'DJ-mix',
|
||||||
|
|
@ -182,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
|
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)
|
manner, and the DJ who does this modification is usually (although not always)
|
||||||
credited in a fairly prominent way.`,
|
credited in a fairly prominent way.`,
|
||||||
value: 'DJ-mix'
|
value: 'DJ-mix',
|
||||||
},
|
},
|
||||||
MIXTAPE: {
|
MIXTAPE: {
|
||||||
name: 'Mixtape/Street',
|
name: 'Mixtape/Street',
|
||||||
|
|
@ -198,18 +201,18 @@ significant proportion of new material, including original production or
|
||||||
original vocals over top of other artists’ instrumentals. They are distinct from
|
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
|
demos in that they are designed for release directly to the public and fans, not
|
||||||
to labels.`,
|
to labels.`,
|
||||||
value: 'Mixtape/Street'
|
value: 'Mixtape/Street',
|
||||||
},
|
},
|
||||||
DEMO: {
|
DEMO: {
|
||||||
name: 'Demo',
|
name: 'Demo',
|
||||||
description: `A release that was recorded for limited circulation or
|
description: `A release that was recorded for limited circulation or
|
||||||
reference use rather than for general public release.`,
|
reference use rather than for general public release.`,
|
||||||
value: 'Demo'
|
value: 'Demo',
|
||||||
},
|
},
|
||||||
NAT: {
|
NAT: {
|
||||||
name: 'Non-Album Track',
|
name: 'Non-Album Track',
|
||||||
description: 'A non-album track (special case).',
|
description: 'A non-album track (special case).',
|
||||||
value: 'NAT'
|
value: 'NAT',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
import { GraphQLObjectType, GraphQLString, GraphQLBoolean } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import { Time } from './scalars'
|
import { Time } from './scalars.js';
|
||||||
import {
|
import {
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
name,
|
name,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
connectionWithExtras,
|
||||||
lifeSpan,
|
linkedQuery,
|
||||||
relationships,
|
} from './helpers.js';
|
||||||
collections,
|
import { aliases } from './alias.js';
|
||||||
rating,
|
import { collections } from './collection.js';
|
||||||
tags,
|
import { lifeSpan } from './life-span.js';
|
||||||
connectionWithExtras
|
import { rating } from './rating.js';
|
||||||
} from './helpers'
|
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',
|
name: 'Event',
|
||||||
description: `An [event](https://musicbrainz.org/doc/Event) refers to an
|
description: `An [event](https://musicbrainz.org/doc/Event) refers to an
|
||||||
organised event which people can attend, and is relevant to MusicBrainz.
|
organised event which people can attend, and is relevant to MusicBrainz.
|
||||||
|
|
@ -32,27 +35,29 @@ Generally this means live performances, like concerts and festivals.`,
|
||||||
lifeSpan,
|
lifeSpan,
|
||||||
time: {
|
time: {
|
||||||
type: Time,
|
type: Time,
|
||||||
description: 'The start time of the event.'
|
description: 'The start time of the event.',
|
||||||
},
|
},
|
||||||
cancelled: {
|
cancelled: {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
description: 'Whether or not the event took place.'
|
description: 'Whether or not the event took place.',
|
||||||
},
|
},
|
||||||
setlist: {
|
setlist: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `A list of songs performed, optionally including links to
|
description: `A list of songs performed, optionally including links to
|
||||||
artists and works. See the [setlist documentation](https://musicbrainz.org/doc/Event/Setlist)
|
artists and works. See the [setlist documentation](https://musicbrainz.org/doc/Event/Setlist)
|
||||||
for syntax and examples.`
|
for syntax and examples.`,
|
||||||
},
|
},
|
||||||
...fieldWithID('type', {
|
...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,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
rating,
|
rating,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const EventConnection = connectionWithExtras(Event)
|
export const EventConnection = connectionWithExtras(Event);
|
||||||
export default Event
|
|
||||||
|
export const events = linkedQuery(EventConnection);
|
||||||
|
|
|
||||||
|
|
@ -1,291 +1,156 @@
|
||||||
import dashify from 'dashify'
|
import GraphQL from 'graphql';
|
||||||
import pascalCase from 'pascalcase'
|
import GraphQLRelay from 'graphql-relay';
|
||||||
import {
|
import { MBID } from './scalars.js';
|
||||||
GraphQLObjectType,
|
import { ReleaseGroupType, ReleaseStatus } from './enums.js';
|
||||||
GraphQLString,
|
import { resolveLinked } from '../resolvers.js';
|
||||||
GraphQLInt,
|
import { toDashed, toPascal, toSingular, toPlural, toWords } from '../util.js';
|
||||||
GraphQLList,
|
|
||||||
GraphQLNonNull
|
const { GraphQLString, GraphQLInt, GraphQLList, GraphQLNonNull } = GraphQL;
|
||||||
} from 'graphql'
|
const {
|
||||||
import {
|
|
||||||
globalIdField,
|
globalIdField,
|
||||||
connectionArgs,
|
|
||||||
connectionDefinitions,
|
connectionDefinitions,
|
||||||
connectionFromArray,
|
forwardConnectionArgs,
|
||||||
forwardConnectionArgs
|
} = GraphQLRelay;
|
||||||
} from 'graphql-relay'
|
|
||||||
import { MBID } from './scalars'
|
|
||||||
import { ReleaseGroupType, ReleaseStatus } from './enums'
|
|
||||||
import Alias from './alias'
|
|
||||||
import ArtistCredit from './artist-credit'
|
|
||||||
import { AreaConnection } from './area'
|
|
||||||
import { ArtistConnection } from './artist'
|
|
||||||
import { CollectionConnection } from './collection'
|
|
||||||
import { EventConnection } from './event'
|
|
||||||
import { InstrumentConnection } from './instrument'
|
|
||||||
import { LabelConnection } from './label'
|
|
||||||
import LifeSpan from './life-span'
|
|
||||||
import { PlaceConnection } from './place'
|
|
||||||
import Rating from './rating'
|
|
||||||
import { RecordingConnection } from './recording'
|
|
||||||
import { RelationshipConnection } from './relationship'
|
|
||||||
import { ReleaseConnection } from './release'
|
|
||||||
import { ReleaseGroupConnection } from './release-group'
|
|
||||||
import { SeriesConnection } from './series'
|
|
||||||
import { TagConnection } from './tag'
|
|
||||||
import { WorkConnection } from './work'
|
|
||||||
import {
|
|
||||||
resolveLinked,
|
|
||||||
resolveRelationship,
|
|
||||||
createSubqueryResolver,
|
|
||||||
includeRelationships
|
|
||||||
} from '../resolvers'
|
|
||||||
|
|
||||||
export const toPascal = pascalCase
|
const TYPE_NAMES = {
|
||||||
export const toDashed = dashify
|
discid: 'Disc',
|
||||||
|
url: 'URL',
|
||||||
|
};
|
||||||
|
|
||||||
export function toPlural (name) {
|
export function resolveType(value, context, info) {
|
||||||
return name.endsWith('s') ? name : name + 's'
|
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 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 resolveHyphenated(obj, args, context, info) {
|
export function resolveHyphenated(obj, args, context, info) {
|
||||||
const name = toDashed(info.fieldName)
|
const name = toDashed(info.fieldName);
|
||||||
return obj[name]
|
return obj[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWithFallback(keys) {
|
export function resolveWithFallback(keys) {
|
||||||
return (obj) => {
|
return (obj) => {
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const key = keys[i]
|
const key = keys[i];
|
||||||
if (key in obj) {
|
if (key in obj) {
|
||||||
return obj[key]
|
return obj[key];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fieldWithID(name, config = {}) {
|
export function fieldWithID(name, config = {}) {
|
||||||
config = {
|
config = {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
resolve: resolveHyphenated,
|
resolve: resolveHyphenated,
|
||||||
...config
|
...config,
|
||||||
}
|
};
|
||||||
const isPlural = config.type instanceof GraphQLList
|
const isPlural = config.type instanceof GraphQLList;
|
||||||
const singularName = isPlural ? toSingular(name) : name
|
const singularName = isPlural ? toSingular(name) : name;
|
||||||
const idName = isPlural ? `${singularName}IDs` : `${name}ID`
|
const idName = isPlural ? `${singularName}IDs` : `${name}ID`;
|
||||||
const s = isPlural ? 's' : ''
|
const s = isPlural ? 's' : '';
|
||||||
const idConfig = {
|
const idConfig = {
|
||||||
type: isPlural ? new GraphQLList(MBID) : MBID,
|
type: isPlural ? new GraphQLList(MBID) : MBID,
|
||||||
description: `The MBID${s} associated with the value${s} of the \`${name}\`
|
description: `The MBID${s} associated with the value${s} of the \`${name}\`
|
||||||
field.`,
|
field.`,
|
||||||
resolve: resolveHyphenated
|
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 {
|
return {
|
||||||
[name]: config,
|
[name]: config,
|
||||||
[idName]: idConfig
|
[idName]: idConfig,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCollectionField(config) {
|
export function createCollectionField(config) {
|
||||||
const typeName = toPlural(toWords(config.type.name.slice(0, -10)))
|
const typeName = toPlural(toWords(config.type.name.slice(0, -10)));
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
description: `The list of ${typeName} found in this collection.`
|
description: `The list of ${typeName} found in this collection.`,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const id = globalIdField()
|
export const id = globalIdField();
|
||||||
export const mbid = {
|
export const mbid = {
|
||||||
type: new GraphQLNonNull(MBID),
|
type: new GraphQLNonNull(MBID),
|
||||||
description: 'The MBID of the entity.',
|
description: 'The MBID of the entity.',
|
||||||
resolve: entity => entity.id
|
resolve: (entity) => entity.id,
|
||||||
}
|
};
|
||||||
export const name = {
|
export const name = {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'The official name of the entity.'
|
description: 'The official name of the entity.',
|
||||||
}
|
};
|
||||||
export const sortName = {
|
export const sortName = {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `The string to use for the purpose of ordering by name (for
|
description: `The string to use for the purpose of ordering by name (for
|
||||||
example, by moving articles like ‘the’ to the end or a person’s last name to
|
example, by moving articles like ‘the’ to the end or a person’s last name to
|
||||||
the front).`,
|
the front).`,
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
}
|
};
|
||||||
export const title = {
|
export const title = {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'The official title of the entity.'
|
description: 'The official title of the entity.',
|
||||||
}
|
};
|
||||||
export const disambiguation = {
|
export const disambiguation = {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'A comment used to help distinguish identically named entitites.'
|
description:
|
||||||
}
|
'A comment used to help distinguish identically named entitites.',
|
||||||
export const lifeSpan = {
|
};
|
||||||
type: LifeSpan,
|
|
||||||
description: `The begin and end dates of the entity’s existence. Its exact
|
|
||||||
meaning depends on the type of entity.`,
|
|
||||||
resolve: resolveHyphenated
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkedQuery (connectionType, { args, ...config } = {}) {
|
export function linkedQuery(connectionType, { args, ...config } = {}) {
|
||||||
const typeName = toPlural(toWords(connectionType.name.slice(0, -10)))
|
const typeName = toPlural(toWords(connectionType.name.slice(0, -10)));
|
||||||
return {
|
return {
|
||||||
type: connectionType,
|
type: connectionType,
|
||||||
description: `A list of ${typeName} linked to this entity.`,
|
description: `A list of ${typeName} linked to this entity.`,
|
||||||
args: {
|
args: {
|
||||||
...args,
|
...args,
|
||||||
...forwardConnectionArgs
|
...forwardConnectionArgs,
|
||||||
},
|
},
|
||||||
resolve: resolveLinked,
|
resolve: resolveLinked,
|
||||||
...config
|
...config,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const aliases = {
|
|
||||||
type: new GraphQLList(Alias),
|
|
||||||
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are used to store
|
|
||||||
alternate names or misspellings.`,
|
|
||||||
resolve: createSubqueryResolver()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const artistCredits = {
|
|
||||||
type: new GraphQLList(ArtistCredit),
|
|
||||||
description: 'The main credited artist(s).',
|
|
||||||
resolve: createSubqueryResolver({ 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.`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rating = {
|
|
||||||
type: Rating,
|
|
||||||
description: 'The rating users have given to this entity.',
|
|
||||||
resolve: createSubqueryResolver({ inc: 'ratings' })
|
|
||||||
}
|
|
||||||
|
|
||||||
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.'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const areas = linkedQuery(AreaConnection)
|
|
||||||
export const artists = linkedQuery(ArtistConnection)
|
|
||||||
export const collections = linkedQuery(CollectionConnection, {
|
|
||||||
description: 'A list of collections containing this entity.'
|
|
||||||
})
|
|
||||||
export const events = linkedQuery(EventConnection)
|
|
||||||
export const instruments = linkedQuery(InstrumentConnection)
|
|
||||||
export const labels = linkedQuery(LabelConnection)
|
|
||||||
export const places = linkedQuery(PlaceConnection)
|
|
||||||
export const recordings = linkedQuery(RecordingConnection)
|
|
||||||
export const releases = linkedQuery(ReleaseConnection, {
|
|
||||||
args: {
|
|
||||||
type: releaseGroupType,
|
|
||||||
status: releaseStatus
|
|
||||||
}
|
|
||||||
})
|
|
||||||
export const releaseGroups = linkedQuery(ReleaseGroupConnection, {
|
|
||||||
args: {
|
|
||||||
type: releaseGroupType
|
|
||||||
}
|
|
||||||
})
|
|
||||||
export const series = linkedQuery(SeriesConnection)
|
|
||||||
export const tags = linkedQuery(TagConnection, {
|
|
||||||
resolve: createSubqueryResolver({}, (value = [], args) => ({
|
|
||||||
totalCount: value.length,
|
|
||||||
...connectionFromArray(value, args)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
export const works = linkedQuery(WorkConnection)
|
|
||||||
|
|
||||||
export const totalCount = {
|
export const totalCount = {
|
||||||
type: GraphQLInt,
|
type: GraphQLInt,
|
||||||
description: `A count of the total number of items in this connection,
|
description: `A count of the total number of items in this connection,
|
||||||
ignoring pagination.`
|
ignoring pagination.`,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const score = {
|
export const score = {
|
||||||
type: GraphQLInt,
|
type: GraphQLInt,
|
||||||
description: `The relevancy score (0–100) assigned by the search engine, if
|
description: `The relevancy score (0–100) 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({
|
return connectionDefinitions({
|
||||||
nodeType,
|
nodeType,
|
||||||
connectionFields: () => ({ totalCount }),
|
connectionFields: () => ({
|
||||||
edgeFields: () => ({ score })
|
nodes: {
|
||||||
}).connectionType
|
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.',
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,27 @@
|
||||||
export { DateType, DiscID, IPI, ISRC, ISWC, MBID, URLString } from './scalars'
|
export {
|
||||||
export { ReleaseGroupType, ReleaseStatus } from './enums'
|
DateType,
|
||||||
export { default as Node } from './node'
|
DiscID,
|
||||||
export { default as Entity, EntityConnection } from './entity'
|
IPI,
|
||||||
export { default as Area, AreaConnection } from './area'
|
ISRC,
|
||||||
export { default as Artist, ArtistConnection } from './artist'
|
ISWC,
|
||||||
export { default as Collection, CollectionConnection } from './collection'
|
MBID,
|
||||||
export { default as Disc } from './disc'
|
URLString,
|
||||||
export { default as Event, EventConnection } from './event'
|
} from './scalars.js';
|
||||||
export { default as Instrument, InstrumentConnection } from './instrument'
|
export { ReleaseGroupType, ReleaseStatus } from './enums.js';
|
||||||
export { default as Label, LabelConnection } from './label'
|
export { Node } from './node.js';
|
||||||
export { default as Place, PlaceConnection } from './place'
|
export { Entity, EntityConnection } from './entity.js';
|
||||||
export { default as Recording, RecordingConnection } from './recording'
|
export { Area, AreaConnection } from './area.js';
|
||||||
export { default as Release, ReleaseConnection } from './release'
|
export { Artist, ArtistConnection } from './artist.js';
|
||||||
export { default as ReleaseGroup, ReleaseGroupConnection } from './release-group'
|
export { Collection, CollectionConnection } from './collection.js';
|
||||||
export { default as Series, SeriesConnection } from './series'
|
export { Disc } from './disc.js';
|
||||||
export { default as Tag, TagConnection } from './tag'
|
export { Event, EventConnection } from './event.js';
|
||||||
export { default as URL, URLConnection } from './url'
|
export { Instrument, InstrumentConnection } from './instrument.js';
|
||||||
export { default as Work, WorkConnection } from './work'
|
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';
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import {
|
import {
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
name,
|
name,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
connectionWithExtras,
|
||||||
relationships,
|
linkedQuery,
|
||||||
collections,
|
} from './helpers.js';
|
||||||
tags,
|
import { aliases } from './alias.js';
|
||||||
connectionWithExtras
|
import { collections } from './collection.js';
|
||||||
} from './helpers'
|
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',
|
name: 'Instrument',
|
||||||
description: `[Instruments](https://musicbrainz.org/doc/Instrument) are
|
description: `[Instruments](https://musicbrainz.org/doc/Instrument) are
|
||||||
devices created or adapted to make musical sounds. Instruments are primarily
|
devices created or adapted to make musical sounds. Instruments are primarily
|
||||||
|
|
@ -29,18 +32,19 @@ used in relationships between two other entities.`,
|
||||||
description: {
|
description: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `A brief description of the main characteristics of the
|
description: `A brief description of the main characteristics of the
|
||||||
instrument.`
|
instrument.`,
|
||||||
},
|
},
|
||||||
...fieldWithID('type', {
|
...fieldWithID('type', {
|
||||||
description: `The type categorises the instrument by the way the sound is
|
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)
|
created, similar to the [Hornbostel-Sachs](https://en.wikipedia.org/wiki/Hornbostel%E2%80%93Sachs)
|
||||||
classification.`
|
classification.`,
|
||||||
}),
|
}),
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const InstrumentConnection = connectionWithExtras(Instrument)
|
export const InstrumentConnection = connectionWithExtras(Instrument);
|
||||||
export default Instrument
|
|
||||||
|
export const instruments = linkedQuery(InstrumentConnection);
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,29 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import { Node } from './node.js';
|
||||||
GraphQLList,
|
import { Entity } from './entity.js';
|
||||||
GraphQLString,
|
import { IPI } from './scalars.js';
|
||||||
GraphQLInt
|
import { Area } from './area.js';
|
||||||
} from 'graphql/type'
|
|
||||||
import Node from './node'
|
|
||||||
import Entity from './entity'
|
|
||||||
import { IPI } from './scalars'
|
|
||||||
import Area from './area'
|
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
name,
|
name,
|
||||||
sortName,
|
sortName,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
|
||||||
lifeSpan,
|
|
||||||
releases,
|
|
||||||
relationships,
|
|
||||||
collections,
|
|
||||||
tags,
|
|
||||||
rating,
|
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
connectionWithExtras
|
connectionWithExtras,
|
||||||
} from './helpers'
|
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',
|
name: 'Label',
|
||||||
description: `[Labels](https://musicbrainz.org/doc/Label) represent mostly
|
description: `[Labels](https://musicbrainz.org/doc/Label) represent mostly
|
||||||
(but not only) imprints. To a lesser extent, a label entity may be created to
|
(but not only) imprints. To a lesser extent, a label entity may be created to
|
||||||
|
|
@ -40,34 +38,35 @@ represent a record company.`,
|
||||||
aliases,
|
aliases,
|
||||||
country: {
|
country: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'The country of origin for the label.'
|
description: 'The country of origin for the label.',
|
||||||
},
|
},
|
||||||
area: {
|
area: {
|
||||||
type: Area,
|
type: Area,
|
||||||
description: 'The area in which the label is based.'
|
description: 'The area in which the label is based.',
|
||||||
},
|
},
|
||||||
lifeSpan,
|
lifeSpan,
|
||||||
labelCode: {
|
labelCode: {
|
||||||
type: GraphQLInt,
|
type: GraphQLInt,
|
||||||
description: `The [“LC” code](https://musicbrainz.org/doc/Label/Label_Code)
|
description: `The [“LC” code](https://musicbrainz.org/doc/Label/Label_Code)
|
||||||
of the label.`
|
of the label.`,
|
||||||
},
|
},
|
||||||
ipis: {
|
ipis: {
|
||||||
type: new GraphQLList(IPI),
|
type: new GraphQLList(IPI),
|
||||||
description: `List of [Interested Parties Information](https://musicbrainz.org/doc/IPI)
|
description: `List of [Interested Parties Information](https://musicbrainz.org/doc/IPI)
|
||||||
codes for the label.`
|
codes for the label.`,
|
||||||
},
|
},
|
||||||
...fieldWithID('type', {
|
...fieldWithID('type', {
|
||||||
description: `A type describing the main activity of the label, e.g.
|
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,
|
releases,
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
rating,
|
rating,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const LabelConnection = connectionWithExtras(Label)
|
export const LabelConnection = connectionWithExtras(Label);
|
||||||
export default Label
|
|
||||||
|
export const labels = linkedQuery(LabelConnection);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
import { GraphQLObjectType, GraphQLBoolean } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import { DateType } from './scalars'
|
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',
|
name: 'LifeSpan',
|
||||||
description: `Fields indicating the begin and end date of an entity’s
|
description: `Fields indicating the begin and end date of an entity’s
|
||||||
lifetime, including whether it has ended (even if the date is unknown).`,
|
lifetime, including whether it has ended (even if the date is unknown).`,
|
||||||
fields: () => ({
|
fields: () => ({
|
||||||
begin: {
|
begin: {
|
||||||
type: DateType,
|
type: DateType,
|
||||||
description: 'The start date of the entity’s life span.'
|
description: 'The start date of the entity’s life span.',
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
type: DateType,
|
type: DateType,
|
||||||
description: 'The end date of the entity’s life span.'
|
description: 'The end date of the entity’s life span.',
|
||||||
},
|
},
|
||||||
ended: {
|
ended: {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
description: 'Whether or not the entity’s life span has ended.'
|
description: 'Whether or not the entity’s life span has ended.',
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
|
export const lifeSpan = {
|
||||||
|
type: LifeSpan,
|
||||||
|
description: `The begin and end dates of the entity’s existence. Its exact
|
||||||
|
meaning depends on the type of entity.`,
|
||||||
|
resolve: resolveHyphenated,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import { Disc } from './disc.js';
|
||||||
GraphQLList,
|
import { Track } from './track.js';
|
||||||
GraphQLString,
|
import { resolveHyphenated, fieldWithID } from './helpers.js';
|
||||||
GraphQLInt
|
import { createSubqueryResolver } from '../resolvers.js';
|
||||||
} from 'graphql/type'
|
|
||||||
import Disc from './disc'
|
|
||||||
import { resolveHyphenated, fieldWithID } from './helpers'
|
|
||||||
|
|
||||||
export default new GraphQLObjectType({
|
const { GraphQLObjectType, GraphQLList, GraphQLString, GraphQLInt } = GraphQL;
|
||||||
|
|
||||||
|
export const Media = new GraphQLObjectType({
|
||||||
name: 'Medium',
|
name: 'Medium',
|
||||||
description: `A medium is the actual physical medium the audio content is
|
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
|
stored upon. This means that each CD in a multi-disc release will be entered as
|
||||||
|
|
@ -17,25 +16,34 @@ cassette) and can optionally also have a title.`,
|
||||||
fields: () => ({
|
fields: () => ({
|
||||||
title: {
|
title: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'The title of this particular medium.'
|
description: 'The title of this particular medium.',
|
||||||
},
|
},
|
||||||
...fieldWithID('format', {
|
...fieldWithID('format', {
|
||||||
description: `The [format](https://musicbrainz.org/doc/Release/Format) of
|
description: `The [format](https://musicbrainz.org/doc/Release/Format) of
|
||||||
the medium (e.g. CD, DVD, vinyl, cassette).`
|
the medium (e.g. CD, DVD, vinyl, cassette).`,
|
||||||
}),
|
}),
|
||||||
position: {
|
position: {
|
||||||
type: GraphQLInt,
|
type: GraphQLInt,
|
||||||
description: `The order of this medium in the release (for example, in a
|
description: `The order of this medium in the release (for example, in a
|
||||||
multi-disc release).`
|
multi-disc release).`,
|
||||||
},
|
},
|
||||||
trackCount: {
|
trackCount: {
|
||||||
type: GraphQLInt,
|
type: GraphQLInt,
|
||||||
description: 'The number of audio tracks on this medium.',
|
description: 'The number of audio tracks on this medium.',
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
discs: {
|
discs: {
|
||||||
type: new GraphQLList(Disc),
|
type: new GraphQLList(Disc),
|
||||||
description: 'A list of physical discs and their disc IDs for this medium.'
|
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',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,17 @@
|
||||||
import { nodeDefinitions, fromGlobalId } from 'graphql-relay'
|
import GraphQLRelay from 'graphql-relay';
|
||||||
import { toDashed } from './helpers'
|
import { toDashed } from '../util.js';
|
||||||
|
import { resolveType } from './helpers.js';
|
||||||
|
|
||||||
const debug = require('debug')('graphbrainz:types/node')
|
const { nodeDefinitions, fromGlobalId } = GraphQLRelay;
|
||||||
|
|
||||||
const TYPE_MODULES = {
|
|
||||||
discid: 'disc'
|
|
||||||
}
|
|
||||||
|
|
||||||
const { nodeInterface, nodeField } = nodeDefinitions(
|
const { nodeInterface, nodeField } = nodeDefinitions(
|
||||||
(globalID, { loaders }) => {
|
(globalID, { loaders }) => {
|
||||||
const { type, id } = fromGlobalId(globalID)
|
const { type, id } = fromGlobalId(globalID);
|
||||||
const entityType = toDashed(type)
|
const entityType = toDashed(type);
|
||||||
return loaders.lookup.load([entityType, id])
|
return loaders.lookup.load([entityType, id]);
|
||||||
},
|
},
|
||||||
(obj) => {
|
resolveType
|
||||||
const type = TYPE_MODULES[obj._type] || obj._type
|
);
|
||||||
try {
|
|
||||||
return require(`./${type}`).default
|
|
||||||
} catch (err) {
|
|
||||||
debug(`Failed to load type: ${type}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export default nodeInterface
|
export const Node = nodeInterface;
|
||||||
export { nodeInterface, nodeField }
|
export { nodeInterface, nodeField };
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import { Degrees } from './scalars'
|
import { Degrees } from './scalars.js';
|
||||||
import Area from './area'
|
import { Area } from './area.js';
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
name,
|
name,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
|
||||||
lifeSpan,
|
|
||||||
events,
|
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
relationships,
|
connectionWithExtras,
|
||||||
collections,
|
linkedQuery,
|
||||||
tags,
|
} from './helpers.js';
|
||||||
connectionWithExtras
|
import { aliases } from './alias.js';
|
||||||
} from './helpers'
|
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({
|
export const Coordinates = new GraphQLObjectType({
|
||||||
name: 'Coordinates',
|
name: 'Coordinates',
|
||||||
|
|
@ -24,16 +27,17 @@ export const Coordinates = new GraphQLObjectType({
|
||||||
fields: () => ({
|
fields: () => ({
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Degrees,
|
type: Degrees,
|
||||||
description: 'The north–south position of a point on the Earth’s surface.'
|
description:
|
||||||
|
'The north–south position of a point on the Earth’s surface.',
|
||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Degrees,
|
type: Degrees,
|
||||||
description: 'The east–west position of a point on the Earth’s surface.'
|
description: 'The east–west position of a point on the Earth’s surface.',
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
const Place = new GraphQLObjectType({
|
export const Place = new GraphQLObjectType({
|
||||||
name: 'Place',
|
name: 'Place',
|
||||||
description: `A [place](https://musicbrainz.org/doc/Place) is a venue, studio,
|
description: `A [place](https://musicbrainz.org/doc/Place) is a venue, studio,
|
||||||
or other place where music is performed, recorded, engineered, etc.`,
|
or other place where music is performed, recorded, engineered, etc.`,
|
||||||
|
|
@ -47,28 +51,29 @@ or other place where music is performed, recorded, engineered, etc.`,
|
||||||
address: {
|
address: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `The address describes the location of the place using the
|
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: {
|
area: {
|
||||||
type: Area,
|
type: Area,
|
||||||
description: `The area entity representing the area, such as the city, in
|
description: `The area entity representing the area, such as the city, in
|
||||||
which the place is located.`
|
which the place is located.`,
|
||||||
},
|
},
|
||||||
coordinates: {
|
coordinates: {
|
||||||
type: Coordinates,
|
type: Coordinates,
|
||||||
description: 'The geographic coordinates of the place.'
|
description: 'The geographic coordinates of the place.',
|
||||||
},
|
},
|
||||||
lifeSpan,
|
lifeSpan,
|
||||||
...fieldWithID('type', {
|
...fieldWithID('type', {
|
||||||
description: `The type categorises the place based on its primary
|
description: `The type categorises the place based on its primary
|
||||||
function.`
|
function.`,
|
||||||
}),
|
}),
|
||||||
events,
|
events,
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const PlaceConnection = connectionWithExtras(Place)
|
export const PlaceConnection = connectionWithExtras(Place);
|
||||||
export default Place
|
|
||||||
|
export const places = linkedQuery(PlaceConnection);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import { createSubqueryResolver } from '../resolvers.js';
|
||||||
GraphQLNonNull,
|
|
||||||
GraphQLInt
|
|
||||||
} from 'graphql/type'
|
|
||||||
|
|
||||||
export default new GraphQLObjectType({
|
const { GraphQLObjectType, GraphQLNonNull, GraphQLInt, GraphQLFloat } = GraphQL;
|
||||||
|
|
||||||
|
export const Rating = new GraphQLObjectType({
|
||||||
name: 'Rating',
|
name: 'Rating',
|
||||||
description: `[Ratings](https://musicbrainz.org/doc/Rating_System) allow users
|
description: `[Ratings](https://musicbrainz.org/doc/Rating_System) allow users
|
||||||
to rate MusicBrainz entities. User may assign a value between 1 and 5; these
|
to rate MusicBrainz entities. User may assign a value between 1 and 5; these
|
||||||
|
|
@ -14,11 +13,17 @@ for the entity.`,
|
||||||
voteCount: {
|
voteCount: {
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
description: 'The number of votes that have contributed to the rating.',
|
description: 'The number of votes that have contributed to the rating.',
|
||||||
resolve: rating => rating['votes-count']
|
resolve: (rating) => rating['votes-count'],
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
type: GraphQLInt,
|
type: GraphQLFloat,
|
||||||
description: 'The average rating value based on the aggregated votes.'
|
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' }),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,27 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import { Node } from './node.js';
|
||||||
GraphQLList,
|
import { Entity } from './entity.js';
|
||||||
GraphQLBoolean
|
import { Duration, ISRC } from './scalars.js';
|
||||||
} from 'graphql/type'
|
|
||||||
import Node from './node'
|
|
||||||
import Entity from './entity'
|
|
||||||
import { Duration, ISRC } from './scalars'
|
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
title,
|
title,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
connectionWithExtras,
|
||||||
artistCredit,
|
linkedQuery,
|
||||||
artistCredits,
|
} from './helpers.js';
|
||||||
artists,
|
import { aliases } from './alias.js';
|
||||||
releases,
|
import { artists } from './artist.js';
|
||||||
relationships,
|
import { artistCredit, artistCredits } from './artist-credit.js';
|
||||||
collections,
|
import { collections } from './collection.js';
|
||||||
rating,
|
import { tags } from './tag.js';
|
||||||
tags,
|
import { rating } from './rating.js';
|
||||||
connectionWithExtras
|
import { relationships } from './relationship.js';
|
||||||
} from './helpers'
|
import { releases } from './release.js';
|
||||||
|
|
||||||
const Recording = new GraphQLObjectType({
|
const { GraphQLObjectType, GraphQLList, GraphQLBoolean } = GraphQL;
|
||||||
|
|
||||||
|
export const Recording = new GraphQLObjectType({
|
||||||
name: 'Recording',
|
name: 'Recording',
|
||||||
description: `A [recording](https://musicbrainz.org/doc/Recording) is an
|
description: `A [recording](https://musicbrainz.org/doc/Recording) is an
|
||||||
entity in MusicBrainz which can be linked to tracks on releases. Each track must
|
entity in MusicBrainz which can be linked to tracks on releases. Each track must
|
||||||
|
|
@ -49,25 +47,37 @@ or mixing.`,
|
||||||
isrcs: {
|
isrcs: {
|
||||||
type: new GraphQLList(ISRC),
|
type: new GraphQLList(ISRC),
|
||||||
description: `A list of [International Standard Recording Codes](https://musicbrainz.org/doc/ISRC)
|
description: `A list of [International Standard Recording Codes](https://musicbrainz.org/doc/ISRC)
|
||||||
(ISRCs) for this recording.`
|
(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: {
|
length: {
|
||||||
type: Duration,
|
type: Duration,
|
||||||
description: `An approximation to the length of the recording, calculated
|
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: {
|
video: {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
description: 'Whether this is a video recording.'
|
description: 'Whether this is a video recording.',
|
||||||
},
|
},
|
||||||
artists,
|
artists,
|
||||||
releases,
|
releases,
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
rating,
|
rating,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const RecordingConnection = connectionWithExtras(Recording)
|
export const RecordingConnection = connectionWithExtras(Recording);
|
||||||
export default Recording
|
|
||||||
|
export const recordings = linkedQuery(RecordingConnection);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
|
import GraphQL from 'graphql';
|
||||||
|
import GraphQLRelay from 'graphql-relay';
|
||||||
|
import { DateType } from './scalars.js';
|
||||||
|
import { Entity } from './entity.js';
|
||||||
import {
|
import {
|
||||||
|
resolveHyphenated,
|
||||||
|
fieldWithID,
|
||||||
|
connectionWithExtras,
|
||||||
|
} from './helpers.js';
|
||||||
|
import { resolveRelationship, includeRelationships } from '../resolvers.js';
|
||||||
|
import { toDashed } from '../util.js';
|
||||||
|
|
||||||
|
const {
|
||||||
GraphQLObjectType,
|
GraphQLObjectType,
|
||||||
GraphQLNonNull,
|
GraphQLNonNull,
|
||||||
GraphQLString,
|
GraphQLString,
|
||||||
GraphQLList,
|
GraphQLList,
|
||||||
GraphQLBoolean
|
GraphQLBoolean,
|
||||||
} from 'graphql/type'
|
} = GraphQL;
|
||||||
import { DateType } from './scalars'
|
const { connectionArgs } = GraphQLRelay;
|
||||||
import Entity from './entity'
|
|
||||||
import {
|
|
||||||
resolveHyphenated,
|
|
||||||
fieldWithID,
|
|
||||||
connectionWithExtras
|
|
||||||
} from './helpers'
|
|
||||||
|
|
||||||
const Relationship = new GraphQLObjectType({
|
export const Relationship = new GraphQLObjectType({
|
||||||
name: 'Relationship',
|
name: 'Relationship',
|
||||||
description: `[Relationships](https://musicbrainz.org/doc/Relationships) are a
|
description: `[Relationships](https://musicbrainz.org/doc/Relationships) are a
|
||||||
way to represent all the different ways in which entities are connected to each
|
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: {
|
target: {
|
||||||
type: new GraphQLNonNull(Entity),
|
type: new GraphQLNonNull(Entity),
|
||||||
description: 'The target entity.',
|
description: 'The target entity.',
|
||||||
resolve: source => {
|
resolve: (source) => {
|
||||||
const targetType = source['target-type']
|
const targetType = source['target-type'];
|
||||||
const target = source[targetType]
|
const target = source[targetType];
|
||||||
target._type = targetType.replace('_', '-')
|
target._type = targetType.replace('_', '-');
|
||||||
return target
|
return target;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
direction: {
|
direction: {
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
type: new GraphQLNonNull(GraphQLString),
|
||||||
description: 'The direction of the relationship.'
|
description: 'The direction of the relationship.',
|
||||||
},
|
},
|
||||||
targetType: {
|
targetType: {
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
type: new GraphQLNonNull(GraphQLString),
|
||||||
description: 'The type of entity on the receiving end of the relationship.',
|
description:
|
||||||
resolve: resolveHyphenated
|
'The type of entity on the receiving end of the relationship.',
|
||||||
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
sourceCredit: {
|
sourceCredit: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `How the source entity was actually credited, if different
|
description: `How the source entity was actually credited, if different
|
||||||
from its main (performance) name.`,
|
from its main (performance) name.`,
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
targetCredit: {
|
targetCredit: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `How the target entity was actually credited, if different
|
description: `How the target entity was actually credited, if different
|
||||||
from its main (performance) name.`,
|
from its main (performance) name.`,
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
begin: {
|
begin: {
|
||||||
type: DateType,
|
type: DateType,
|
||||||
description: 'The date on which the relationship became applicable.'
|
description: 'The date on which the relationship became applicable.',
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
type: DateType,
|
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: {
|
ended: {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
description: 'Whether the relationship still applies.'
|
description: 'Whether the relationship still applies.',
|
||||||
},
|
},
|
||||||
attributes: {
|
attributes: {
|
||||||
type: new GraphQLList(GraphQLString),
|
type: new GraphQLList(GraphQLString),
|
||||||
description: `Attributes which modify the relationship. There is a [list
|
description: `Attributes which modify the relationship. There is a [list
|
||||||
of all attributes](https://musicbrainz.org/relationship-attributes), but the
|
of all attributes](https://musicbrainz.org/relationship-attributes), but the
|
||||||
attributes which are available, and how they should be used, depends on 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`
|
// There doesn't seem to be any documentation for the `attribute-values`
|
||||||
// field.
|
// field.
|
||||||
// attributeValues: {},
|
// attributeValues: {},
|
||||||
...fieldWithID('type', {
|
...fieldWithID('type', {
|
||||||
description: 'The type of relationship.'
|
description: 'The type of relationship.',
|
||||||
})
|
}),
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const RelationshipConnection = connectionWithExtras(Relationship)
|
export const RelationshipConnection = connectionWithExtras(Relationship);
|
||||||
export default 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { GraphQLObjectType } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import { DateType } from './scalars'
|
import { DateType } from './scalars.js';
|
||||||
import Area from './area'
|
import { Area } from './area.js';
|
||||||
|
|
||||||
export default new GraphQLObjectType({
|
const { GraphQLObjectType } = GraphQL;
|
||||||
|
|
||||||
|
export const ReleaseEvent = new GraphQLObjectType({
|
||||||
name: 'ReleaseEvent',
|
name: 'ReleaseEvent',
|
||||||
description: `The date on which a release was issued in a country/region with
|
description: `The date on which a release was issued in a country/region with
|
||||||
a particular label, catalog number, barcode, and format.`,
|
a particular label, catalog number, barcode, and format.`,
|
||||||
fields: () => ({
|
fields: () => ({
|
||||||
area: { type: Area },
|
area: { type: Area },
|
||||||
date: { type: DateType }
|
date: { type: DateType },
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,31 @@
|
||||||
import { GraphQLObjectType, GraphQLList } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import { ReleaseGroupCoverArt } from './cover-art'
|
import { DateType } from './scalars.js';
|
||||||
import { DateType } from './scalars'
|
import { ReleaseGroupType } from './enums.js';
|
||||||
import { ReleaseGroupType } from './enums'
|
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
title,
|
title,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
|
||||||
artistCredit,
|
|
||||||
artistCredits,
|
|
||||||
artists,
|
|
||||||
releases,
|
|
||||||
relationships,
|
|
||||||
collections,
|
|
||||||
rating,
|
|
||||||
tags,
|
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
|
releaseGroupType,
|
||||||
resolveHyphenated,
|
resolveHyphenated,
|
||||||
connectionWithExtras
|
connectionWithExtras,
|
||||||
} from './helpers'
|
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',
|
name: 'ReleaseGroup',
|
||||||
description: `A [release group](https://musicbrainz.org/doc/Release_Group) is
|
description: `A [release group](https://musicbrainz.org/doc/Release_Group) is
|
||||||
used to group several different releases into a single logical entity. Every
|
used to group several different releases into a single logical entity. Every
|
||||||
|
|
@ -45,36 +47,33 @@ album – it doesn’t matter how many CDs or editions/versions it had.`,
|
||||||
firstReleaseDate: {
|
firstReleaseDate: {
|
||||||
type: DateType,
|
type: DateType,
|
||||||
description: 'The date of the earliest release in the group.',
|
description: 'The date of the earliest release in the group.',
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
...fieldWithID('primaryType', {
|
...fieldWithID('primaryType', {
|
||||||
type: ReleaseGroupType,
|
type: ReleaseGroupType,
|
||||||
description: `The [type](https://musicbrainz.org/doc/Release_Group/Type)
|
description: `The [type](https://musicbrainz.org/doc/Release_Group/Type)
|
||||||
of a release group describes what kind of releases the release group represents,
|
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
|
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', {
|
...fieldWithID('secondaryTypes', {
|
||||||
type: new GraphQLList(ReleaseGroupType),
|
type: new GraphQLList(ReleaseGroupType),
|
||||||
description: `Additional [types](https://musicbrainz.org/doc/Release_Group/Type)
|
description: `Additional [types](https://musicbrainz.org/doc/Release_Group/Type)
|
||||||
that apply to this release group.`
|
that apply to this release group.`,
|
||||||
}),
|
}),
|
||||||
coverArt: {
|
|
||||||
type: ReleaseGroupCoverArt,
|
|
||||||
description: `The cover art for a release group, obtained from the [Cover
|
|
||||||
Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive).`,
|
|
||||||
resolve: (releaseGroup, args, { loaders }) => {
|
|
||||||
return loaders.coverArt.load(['release-group', releaseGroup.id])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
artists,
|
artists,
|
||||||
releases,
|
releases,
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
rating,
|
rating,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ReleaseGroupConnection = connectionWithExtras(ReleaseGroup)
|
export const ReleaseGroupConnection = connectionWithExtras(ReleaseGroup);
|
||||||
export default ReleaseGroup
|
|
||||||
|
export const releaseGroups = linkedQuery(ReleaseGroupConnection, {
|
||||||
|
args: {
|
||||||
|
type: releaseGroupType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,35 @@
|
||||||
import {
|
import GraphQL from 'graphql';
|
||||||
GraphQLObjectType,
|
import { Node } from './node.js';
|
||||||
GraphQLNonNull,
|
import { Entity } from './entity.js';
|
||||||
GraphQLString,
|
import { ASIN, DateType } from './scalars.js';
|
||||||
GraphQLList
|
import { Media } from './media.js';
|
||||||
} from 'graphql/type'
|
import { ReleaseStatus } from './enums.js';
|
||||||
import Node from './node'
|
import { ReleaseEvent } from './release-event.js';
|
||||||
import Entity from './entity'
|
|
||||||
import { ASIN, DateType } from './scalars'
|
|
||||||
import Media from './media'
|
|
||||||
import { ReleaseCoverArt } from './cover-art'
|
|
||||||
import { ReleaseStatus } from './enums'
|
|
||||||
import ReleaseEvent from './release-event'
|
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
title,
|
title,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
|
||||||
artistCredit,
|
|
||||||
artistCredits,
|
|
||||||
artists,
|
|
||||||
labels,
|
|
||||||
recordings,
|
|
||||||
releaseGroups,
|
|
||||||
relationships,
|
|
||||||
collections,
|
|
||||||
tags,
|
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
|
releaseGroupType,
|
||||||
|
releaseStatus,
|
||||||
resolveHyphenated,
|
resolveHyphenated,
|
||||||
connectionWithExtras
|
connectionWithExtras,
|
||||||
} from './helpers'
|
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',
|
name: 'Release',
|
||||||
description: `A [release](https://musicbrainz.org/doc/Release) represents the
|
description: `A [release](https://musicbrainz.org/doc/Release) represents the
|
||||||
unique release (i.e. issuing) of a product on a specific date with specific
|
unique release (i.e. issuing) of a product on a specific date with specific
|
||||||
|
|
@ -50,61 +48,48 @@ MusicBrainz as one release.`,
|
||||||
releaseEvents: {
|
releaseEvents: {
|
||||||
type: new GraphQLList(ReleaseEvent),
|
type: new GraphQLList(ReleaseEvent),
|
||||||
description: 'The release events for this release.',
|
description: 'The release events for this release.',
|
||||||
resolve: resolveHyphenated
|
resolve: resolveHyphenated,
|
||||||
},
|
},
|
||||||
date: {
|
date: {
|
||||||
type: DateType,
|
type: DateType,
|
||||||
description: `The [release date](https://musicbrainz.org/doc/Release/Date)
|
description: `The [release date](https://musicbrainz.org/doc/Release/Date)
|
||||||
is the date in which a release was made available through some sort of
|
is the date in which a release was made available through some sort of
|
||||||
distribution mechanism.`
|
distribution mechanism.`,
|
||||||
},
|
},
|
||||||
country: {
|
country: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'The country in which the release was issued.'
|
description: 'The country in which the release was issued.',
|
||||||
},
|
},
|
||||||
asin: {
|
asin: {
|
||||||
type: ASIN,
|
type: ASIN,
|
||||||
description: `The [Amazon Standard Identification Number](https://musicbrainz.org/doc/ASIN)
|
description: `The [Amazon Standard Identification Number](https://musicbrainz.org/doc/ASIN)
|
||||||
of the release.`
|
of the release.`,
|
||||||
},
|
},
|
||||||
barcode: {
|
barcode: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `The [barcode](https://en.wikipedia.org/wiki/Barcode), if the
|
description: `The [barcode](https://en.wikipedia.org/wiki/Barcode), if the
|
||||||
release has one. The most common types found on releases are 12-digit
|
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
|
[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).`,
|
||||||
},
|
|
||||||
coverArt: {
|
|
||||||
type: new GraphQLNonNull(ReleaseCoverArt),
|
|
||||||
description: `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).`,
|
|
||||||
resolve: (release, args, { loaders }) => {
|
|
||||||
const coverArt = release['cover-art-archive']
|
|
||||||
if (coverArt) {
|
|
||||||
coverArt._release = release.id
|
|
||||||
return coverArt
|
|
||||||
}
|
|
||||||
return loaders.coverArt.load(['release', release.id])
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
...fieldWithID('status', {
|
...fieldWithID('status', {
|
||||||
type: ReleaseStatus,
|
type: ReleaseStatus,
|
||||||
description: 'The status describes how “official” a release is.'
|
description: 'The status describes how “official” a release is.',
|
||||||
}),
|
}),
|
||||||
...fieldWithID('packaging', {
|
...fieldWithID('packaging', {
|
||||||
description: `The physical packaging that accompanies the release. See
|
description: `The physical packaging that accompanies the release. See
|
||||||
the [list of packaging](https://musicbrainz.org/doc/Release/Packaging) for more
|
the [list of packaging](https://musicbrainz.org/doc/Release/Packaging) for more
|
||||||
information.`
|
information.`,
|
||||||
}),
|
}),
|
||||||
quality: {
|
quality: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: `Data quality indicates how good the data for a release is.
|
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
|
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: {
|
media: {
|
||||||
type: new GraphQLList(Media),
|
type: new GraphQLList(Media),
|
||||||
description: 'The media on which the release was distributed.'
|
description: 'The media on which the release was distributed.',
|
||||||
},
|
},
|
||||||
artists,
|
artists,
|
||||||
labels,
|
labels,
|
||||||
|
|
@ -112,9 +97,15 @@ It is not a mark of how good or bad the music itself is – for that, use
|
||||||
releaseGroups,
|
releaseGroups,
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ReleaseConnection = connectionWithExtras(Release)
|
export const ReleaseConnection = connectionWithExtras(Release);
|
||||||
export default Release
|
|
||||||
|
export const releases = linkedQuery(ReleaseConnection, {
|
||||||
|
args: {
|
||||||
|
type: releaseGroupType,
|
||||||
|
status: releaseStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,71 @@
|
||||||
import { Kind } from 'graphql/language'
|
import GraphQL from 'graphql';
|
||||||
import { GraphQLScalarType } from 'graphql/type'
|
|
||||||
|
const { Kind, GraphQLScalarType } = GraphQL;
|
||||||
|
|
||||||
function createScalar(config) {
|
function createScalar(config) {
|
||||||
return new GraphQLScalarType({
|
return new GraphQLScalarType({
|
||||||
serialize: value => value,
|
serialize: (value) => value,
|
||||||
parseValue: value => value,
|
parseValue: (value) => value,
|
||||||
parseLiteral(ast) {
|
parseLiteral(ast) {
|
||||||
if (ast.kind === Kind.STRING) {
|
if (ast.kind === Kind.STRING) {
|
||||||
return ast.value
|
return ast.value;
|
||||||
}
|
}
|
||||||
return null
|
return undefined;
|
||||||
},
|
},
|
||||||
...config
|
...config,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
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-]+)?$/
|
const locale = /^([a-z]{2})(_[A-Z]{2})?(\.[a-zA-Z0-9-]+)?$/;
|
||||||
// Be extremely lenient; just prevent major input errors.
|
// Be extremely lenient; just prevent major input errors.
|
||||||
const url = /^\w+:\/\/[\w-]+\.\w+/
|
const url = /^\w+:\/\/[\w-]+\.\w+/;
|
||||||
|
|
||||||
function validateMBID(value) {
|
function validateMBID(value) {
|
||||||
if (typeof value === 'string' && uuid.test(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) {
|
if (value >= 0) {
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
throw new TypeError(`Expected positive value: ${value}`)
|
throw new TypeError(`Expected positive value: ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLocale(value) {
|
function validateLocale(value) {
|
||||||
if (typeof value === 'string' && locale.test(value)) {
|
if (typeof value === 'string' && locale.test(value)) {
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
throw new TypeError(`Malformed locale: ${value}`)
|
throw new TypeError(`Malformed locale: ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateURL(value) {
|
function validateURL(value) {
|
||||||
if (typeof value === 'string' && url.test(value)) {
|
if (typeof value === 'string' && url.test(value)) {
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
throw new TypeError(`Malformed URL: ${value}`)
|
throw new TypeError(`Malformed URL: ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ASIN = createScalar({
|
export const ASIN = createScalar({
|
||||||
name: 'ASIN',
|
name: 'ASIN',
|
||||||
description: `An [Amazon Standard Identification Number](https://musicbrainz.org/doc/ASIN)
|
description: `An [Amazon Standard Identification Number](https://musicbrainz.org/doc/ASIN)
|
||||||
(ASIN) is a 10-character alphanumeric unique identifier assigned by Amazon.com
|
(ASIN) is a 10-character alphanumeric unique identifier assigned by Amazon.com
|
||||||
and its partners for product identification within the Amazon organization.`
|
and its partners for product identification within the Amazon organization.`,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const DateType = createScalar({
|
export const DateType = createScalar({
|
||||||
name: 'Date',
|
name: 'Date',
|
||||||
description: 'Year, month (optional), and day (optional) in YYYY-MM-DD format.'
|
description:
|
||||||
})
|
'Year, month (optional), and day (optional) in YYYY-MM-DD format.',
|
||||||
|
});
|
||||||
|
|
||||||
export const Degrees = createScalar({
|
export const Degrees = createScalar({
|
||||||
name: 'Degrees',
|
name: 'Degrees',
|
||||||
description: 'Decimal degrees, used for latitude and longitude.'
|
description: 'Decimal degrees, used for latitude and longitude.',
|
||||||
})
|
});
|
||||||
|
|
||||||
export const DiscID = createScalar({
|
export const DiscID = createScalar({
|
||||||
name: 'DiscID',
|
name: 'DiscID',
|
||||||
|
|
@ -79,8 +81,8 @@ Different pressing of a CD often have slightly different frame offsets, and
|
||||||
hence different disc IDs.
|
hence different disc IDs.
|
||||||
|
|
||||||
Conversely, two different CDs may happen to have exactly the same set of frame
|
Conversely, two different CDs may happen to have exactly the same set of frame
|
||||||
offsets and hence the same disc ID.`
|
offsets and hence the same disc ID.`,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const Duration = createScalar({
|
export const Duration = createScalar({
|
||||||
name: 'Duration',
|
name: 'Duration',
|
||||||
|
|
@ -89,25 +91,25 @@ export const Duration = createScalar({
|
||||||
parseValue: validatePositive,
|
parseValue: validatePositive,
|
||||||
parseLiteral(ast) {
|
parseLiteral(ast) {
|
||||||
if (ast.kind === Kind.INT) {
|
if (ast.kind === Kind.INT) {
|
||||||
return validatePositive(parseInt(ast.value, 10))
|
return validatePositive(parseInt(ast.value, 10));
|
||||||
}
|
}
|
||||||
return null
|
return undefined;
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const IPI = createScalar({
|
export const IPI = createScalar({
|
||||||
name: 'IPI',
|
name: 'IPI',
|
||||||
description: `An [Interested Parties Information](https://musicbrainz.org/doc/IPI)
|
description: `An [Interested Parties Information](https://musicbrainz.org/doc/IPI)
|
||||||
(IPI) code is an identifying number assigned by the CISAC database for musical
|
(IPI) code is an identifying number assigned by the CISAC database for musical
|
||||||
rights management.`
|
rights management.`,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ISNI = createScalar({
|
export const ISNI = createScalar({
|
||||||
name: 'ISNI',
|
name: 'ISNI',
|
||||||
description: `The [International Standard Name Identifier](https://musicbrainz.org/doc/ISNI)
|
description: `The [International Standard Name Identifier](https://musicbrainz.org/doc/ISNI)
|
||||||
(ISNI) is an ISO standard for uniquely identifying the public identities of
|
(ISNI) is an ISO standard for uniquely identifying the public identities of
|
||||||
contributors to media content.`
|
contributors to media content.`,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ISRC = createScalar({
|
export const ISRC = createScalar({
|
||||||
name: 'ISRC',
|
name: 'ISRC',
|
||||||
|
|
@ -120,15 +122,15 @@ not the song itself. Therefore, different recordings, edits, remixes and
|
||||||
remasters of the same song will each be assigned their own ISRC. However, note
|
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.
|
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)
|
Songs are identified by analogous [International Standard Musical Work Codes](https://musicbrainz.org/doc/ISWC)
|
||||||
(ISWCs).`
|
(ISWCs).`,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ISWC = createScalar({
|
export const ISWC = createScalar({
|
||||||
name: 'ISWC',
|
name: 'ISWC',
|
||||||
description: `The [International Standard Musical Work Code](https://musicbrainz.org/doc/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 /
|
(ISWC) is an ISO standard similar to ISBNs for identifying musical works /
|
||||||
compositions.`
|
compositions.`,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const Locale = createScalar({
|
export const Locale = createScalar({
|
||||||
name: 'Locale',
|
name: 'Locale',
|
||||||
|
|
@ -137,11 +139,11 @@ export const Locale = createScalar({
|
||||||
parseValue: validateLocale,
|
parseValue: validateLocale,
|
||||||
parseLiteral(ast) {
|
parseLiteral(ast) {
|
||||||
if (ast.kind === Kind.STRING) {
|
if (ast.kind === Kind.STRING) {
|
||||||
return validateLocale(ast.value)
|
return validateLocale(ast.value);
|
||||||
}
|
}
|
||||||
return null
|
return undefined;
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const MBID = createScalar({
|
export const MBID = createScalar({
|
||||||
name: 'MBID',
|
name: 'MBID',
|
||||||
|
|
@ -151,16 +153,16 @@ export const MBID = createScalar({
|
||||||
parseValue: validateMBID,
|
parseValue: validateMBID,
|
||||||
parseLiteral(ast) {
|
parseLiteral(ast) {
|
||||||
if (ast.kind === Kind.STRING) {
|
if (ast.kind === Kind.STRING) {
|
||||||
return validateMBID(ast.value)
|
return validateMBID(ast.value);
|
||||||
}
|
}
|
||||||
return null
|
return undefined;
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export const Time = createScalar({
|
export const Time = createScalar({
|
||||||
name: 'Time',
|
name: 'Time',
|
||||||
description: 'A time of day, in 24-hour hh:mm notation.'
|
description: 'A time of day, in 24-hour hh:mm notation.',
|
||||||
})
|
});
|
||||||
|
|
||||||
export const URLString = createScalar({
|
export const URLString = createScalar({
|
||||||
name: 'URLString',
|
name: 'URLString',
|
||||||
|
|
@ -169,8 +171,8 @@ export const URLString = createScalar({
|
||||||
parseValue: validateURL,
|
parseValue: validateURL,
|
||||||
parseLiteral(ast) {
|
parseLiteral(ast) {
|
||||||
if (ast.kind === Kind.STRING) {
|
if (ast.kind === Kind.STRING) {
|
||||||
return validateURL(ast.value)
|
return validateURL(ast.value);
|
||||||
}
|
}
|
||||||
return null
|
return undefined;
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import { GraphQLObjectType } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
name,
|
name,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
relationships,
|
|
||||||
collections,
|
|
||||||
tags,
|
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
connectionWithExtras
|
connectionWithExtras,
|
||||||
} from './helpers'
|
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',
|
name: 'Series',
|
||||||
description: `A [series](https://musicbrainz.org/doc/Series) is a sequence of
|
description: `A [series](https://musicbrainz.org/doc/Series) is a sequence of
|
||||||
separate release groups, releases, recordings, works or events with a common
|
separate release groups, releases, recordings, works or events with a common
|
||||||
|
|
@ -26,13 +29,14 @@ theme.`,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
...fieldWithID('type', {
|
...fieldWithID('type', {
|
||||||
description: `The type primarily describes what type of entity the series
|
description: `The type primarily describes what type of entity the series
|
||||||
contains.`
|
contains.`,
|
||||||
}),
|
}),
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const SeriesConnection = connectionWithExtras(Series)
|
export const SeriesConnection = connectionWithExtras(Series);
|
||||||
export default Series
|
|
||||||
|
export const series = linkedQuery(SeriesConnection);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
GraphQLObjectType,
|
||||||
GraphQLNonNull,
|
GraphQLNonNull,
|
||||||
GraphQLString,
|
GraphQLString,
|
||||||
GraphQLInt
|
GraphQLInt,
|
||||||
} from 'graphql/type'
|
} = GraphQL;
|
||||||
import { connectionWithExtras } from './helpers'
|
const { connectionFromArray } = GraphQLRelay;
|
||||||
|
|
||||||
const Tag = new GraphQLObjectType({
|
export const Tag = new GraphQLObjectType({
|
||||||
name: 'Tag',
|
name: 'Tag',
|
||||||
description: `[Tags](https://musicbrainz.org/tags) are a way to mark entities
|
description: `[Tags](https://musicbrainz.org/tags) are a way to mark entities
|
||||||
with extra information – for example, the genres that apply to an artist,
|
with extra information – for example, the genres that apply to an artist,
|
||||||
|
|
@ -14,14 +19,24 @@ release, or recording.`,
|
||||||
fields: () => ({
|
fields: () => ({
|
||||||
name: {
|
name: {
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
type: new GraphQLNonNull(GraphQLString),
|
||||||
description: 'The tag label.'
|
description: 'The tag label.',
|
||||||
},
|
},
|
||||||
count: {
|
count: {
|
||||||
type: GraphQLInt,
|
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 const TagConnection = connectionWithExtras(Tag);
|
||||||
export default 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
44
src/types/track.js
Normal 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 track’s 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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { GraphQLObjectType, GraphQLNonNull } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import { URLString } from './scalars'
|
import { URLString } from './scalars.js';
|
||||||
import { id, mbid, relationships, connectionWithExtras } from './helpers'
|
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',
|
name: 'URL',
|
||||||
description: `A [URL](https://musicbrainz.org/doc/URL) pointing to a resource
|
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
|
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,
|
mbid,
|
||||||
resource: {
|
resource: {
|
||||||
type: new GraphQLNonNull(URLString),
|
type: new GraphQLNonNull(URLString),
|
||||||
description: 'The actual URL string.'
|
description: 'The actual URL string.',
|
||||||
},
|
},
|
||||||
relationships
|
relationships,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const URLConnection = connectionWithExtras(URL)
|
export const URLConnection = connectionWithExtras(URL);
|
||||||
export default URL
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type'
|
import GraphQL from 'graphql';
|
||||||
import Node from './node'
|
import { Node } from './node.js';
|
||||||
import Entity from './entity'
|
import { Entity } from './entity.js';
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
mbid,
|
mbid,
|
||||||
title,
|
title,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
aliases,
|
|
||||||
artists,
|
|
||||||
relationships,
|
|
||||||
collections,
|
|
||||||
rating,
|
|
||||||
tags,
|
|
||||||
fieldWithID,
|
fieldWithID,
|
||||||
connectionWithExtras
|
connectionWithExtras,
|
||||||
} from './helpers'
|
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',
|
name: 'Work',
|
||||||
description: `A [work](https://musicbrainz.org/doc/Work) is a distinct
|
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
|
intellectual or artistic creation, which can be expressed in the form of one or
|
||||||
|
|
@ -31,22 +34,23 @@ more audio recordings.`,
|
||||||
iswcs: {
|
iswcs: {
|
||||||
type: new GraphQLList(GraphQLString),
|
type: new GraphQLList(GraphQLString),
|
||||||
description: `A list of [ISWCs](https://musicbrainz.org/doc/ISWC) assigned
|
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: {
|
language: {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
description: 'The language in which the work was originally written.'
|
description: 'The language in which the work was originally written.',
|
||||||
},
|
},
|
||||||
...fieldWithID('type', {
|
...fieldWithID('type', {
|
||||||
description: 'The type of work.'
|
description: 'The type of work.',
|
||||||
}),
|
}),
|
||||||
artists,
|
artists,
|
||||||
relationships,
|
relationships,
|
||||||
collections,
|
collections,
|
||||||
rating,
|
rating,
|
||||||
tags
|
tags,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const WorkConnection = connectionWithExtras(Work)
|
export const WorkConnection = connectionWithExtras(Work);
|
||||||
export default Work
|
|
||||||
|
export const works = linkedQuery(WorkConnection);
|
||||||
|
|
|
||||||
102
src/util.js
102
src/util.js
|
|
@ -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') {
|
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) => {
|
const reducer = (fields, selection) => {
|
||||||
if (selection.kind === 'FragmentSpread') {
|
if (selection.kind === 'FragmentSpread') {
|
||||||
const name = selection.name.value
|
const name = selection.name.value;
|
||||||
const fragment = fragments[name]
|
const fragment = fragments[name];
|
||||||
if (!fragment) {
|
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 {
|
} 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,
|
export function prettyPrint(
|
||||||
colors = true,
|
obj,
|
||||||
breakLength = 120 } = {}) {
|
{ depth = 5, colors = true, breakLength = 120 } = {}
|
||||||
console.log(util.inspect(obj, { depth, colors, breakLength }))
|
) {
|
||||||
|
console.log(util.inspect(obj, { depth, colors, breakLength }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toFilteredArray(obj) {
|
export function toFilteredArray(obj) {
|
||||||
return (Array.isArray(obj) ? obj : [obj]).filter(x => x)
|
return (Array.isArray(obj) ? obj : [obj]).filter((x) => x);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extendIncludes(includes, moreIncludes) {
|
export function extendIncludes(includes, moreIncludes) {
|
||||||
includes = toFilteredArray(includes)
|
includes = toFilteredArray(includes);
|
||||||
moreIncludes = toFilteredArray(moreIncludes)
|
moreIncludes = toFilteredArray(moreIncludes);
|
||||||
const seen = {}
|
const seen = {};
|
||||||
return includes.concat(moreIncludes).filter(x => {
|
return includes.concat(moreIncludes).filter((x) => {
|
||||||
if (seen[x]) {
|
if (seen[x]) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
seen[x] = true
|
seen[x] = true;
|
||||||
return 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
1475
test/_schema.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,14 @@
|
||||||
import test from 'ava'
|
import test from 'ava';
|
||||||
import Client from '../../src/api/client'
|
import Client from '../../src/api/client.js';
|
||||||
|
|
||||||
test('parseErrorMessage() returns the body or status code', t => {
|
test('parseErrorMessage() returns the input error by default', (t) => {
|
||||||
const client = new Client()
|
const client = new Client();
|
||||||
t.is(client.parseErrorMessage({ statusCode: 500 }, 'something went wrong'), 'something went wrong')
|
const error = {
|
||||||
t.is(client.parseErrorMessage({ statusCode: 500 }, ''), '500')
|
name: 'HTTPError',
|
||||||
t.is(client.parseErrorMessage({ statusCode: 404 }, {}), '404')
|
response: {
|
||||||
})
|
statusCode: 500,
|
||||||
|
body: 'something went wrong',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
t.is(client.parseErrorMessage(error), error);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import test from 'ava'
|
|
||||||
import { CoverArtArchiveError } from '../../src/api'
|
|
||||||
import client from '../helpers/client/cover-art-archive'
|
|
||||||
|
|
||||||
test('can retrieve a front image URL', t => {
|
|
||||||
return client.imageURL('release', '76df3287-6cda-33eb-8e9a-044b5e15ffdd', 'front')
|
|
||||||
.then(url => {
|
|
||||||
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', t => {
|
|
||||||
return client.imageURL('release', '76df3287-6cda-33eb-8e9a-044b5e15ffdd', 'back')
|
|
||||||
.then(url => {
|
|
||||||
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', t => {
|
|
||||||
return client.images('release', '76df3287-6cda-33eb-8e9a-044b5e15ffdd')
|
|
||||||
.then(data => {
|
|
||||||
t.is(data.release, 'http://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.throws(client.images('release', 'xyz'), CoverArtArchiveError)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('uses the default error impementation if there is no HTML error', t => {
|
|
||||||
t.is(client.parseErrorMessage({ statusCode: 501 }, 'yikes'), 'yikes')
|
|
||||||
t.is(client.parseErrorMessage({ statusCode: 500 }, ''), '500')
|
|
||||||
t.is(client.parseErrorMessage({ statusCode: 404 }, null), '404')
|
|
||||||
})
|
|
||||||
116
test/api/fixtures/musicbrainz.js.nock
Normal file
116
test/api/fixtures/musicbrainz.js.nock
Normal 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",
|
||||||
|
[
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
@ -1,70 +1,87 @@
|
||||||
import test from 'ava'
|
import test from 'ava';
|
||||||
import MusicBrainz, { MusicBrainzError } from '../../src/api'
|
import MusicBrainz from '../../src/api/index.js';
|
||||||
import client from '../helpers/client/musicbrainz'
|
import client from '../helpers/client/musicbrainz.js';
|
||||||
|
|
||||||
test('getLookupURL() generates a lookup URL', t => {
|
test('getLookupURL() generates a lookup URL', (t) => {
|
||||||
t.is(client.getLookupURL('artist', 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8', {
|
t.is(
|
||||||
inc: ['recordings', 'release-groups']
|
client.getLookupURL('artist', 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8', {
|
||||||
}), 'artist/c8da2e40-bd28-4d4e-813a-bd2f51958ba8?inc=recordings%2Brelease-groups')
|
inc: ['recordings', 'release-groups'],
|
||||||
})
|
}),
|
||||||
|
'artist/c8da2e40-bd28-4d4e-813a-bd2f51958ba8?inc=recordings%2Brelease-groups'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('getBrowseURL() generates a browse URL', t => {
|
test('getBrowseURL() generates a browse URL', (t) => {
|
||||||
t.is(client.getBrowseURL('recording', {
|
t.is(
|
||||||
|
client.getBrowseURL('recording', {
|
||||||
artist: 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8',
|
artist: 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8',
|
||||||
limit: null,
|
limit: null,
|
||||||
offset: 0
|
offset: 0,
|
||||||
}), 'recording?artist=c8da2e40-bd28-4d4e-813a-bd2f51958ba8&offset=0')
|
}),
|
||||||
})
|
'recording?artist=c8da2e40-bd28-4d4e-813a-bd2f51958ba8&offset=0'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('getSearchURL() generates a search URL', t => {
|
test('getSearchURL() generates a search URL', (t) => {
|
||||||
t.is(client.getSearchURL('artist', 'Lures', { inc: null }), 'artist?query=Lures')
|
t.is(
|
||||||
})
|
client.getSearchURL('artist', 'Lures', { inc: null }),
|
||||||
|
'artist?query=Lures'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('lookup() sends a lookup query', t => {
|
test('lookup() sends a lookup query', async (t) => {
|
||||||
return client.lookup('artist', 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8').then(response => {
|
const response = await client.lookup(
|
||||||
t.is(response.id, 'c8da2e40-bd28-4d4e-813a-bd2f51958ba8')
|
'artist',
|
||||||
t.is(response.type, 'Group')
|
'c8da2e40-bd28-4d4e-813a-bd2f51958ba8'
|
||||||
})
|
);
|
||||||
})
|
|
||||||
|
|
||||||
test('rejects the promise when the API returns an error', t => {
|
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', {
|
const req = client.lookup('artist', '5b11f4ce-a62d-471e-81fc-a69a8278c7da', {
|
||||||
inc: ['foobar']
|
inc: ['foobar'],
|
||||||
})
|
});
|
||||||
return t.throws(req, MusicBrainzError)
|
return t.throwsAsync(req, {
|
||||||
})
|
name: 'MusicBrainzError',
|
||||||
|
message: 'foobar is not a valid inc parameter for the artist resource.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('shouldRetry() retries only 5xx responses from MusicBrainz', t => {
|
test('rejects non-MusicBrainz errors', (t) => {
|
||||||
t.true(client.shouldRetry(new MusicBrainzError('error', 500)))
|
const client = new MusicBrainz({ baseURL: '$!@#$' });
|
||||||
t.true(client.shouldRetry(new MusicBrainzError('error', 501)))
|
return t.throwsAsync(
|
||||||
t.true(client.shouldRetry(new MusicBrainzError('error', 598)))
|
client.get('artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da'),
|
||||||
t.true(client.shouldRetry(new MusicBrainzError('error', 599)))
|
{
|
||||||
t.false(client.shouldRetry(new MusicBrainzError('error', 404)))
|
name: 'TypeError',
|
||||||
t.false(client.shouldRetry(new MusicBrainzError('error', 499)))
|
}
|
||||||
t.false(client.shouldRetry(new MusicBrainzError('error', 600)))
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
test('shouldRetry() retries only transient local connection issues', t => {
|
test('uses the default error impementation if there is no JSON error', (t) => {
|
||||||
t.true(client.shouldRetry({ code: 'ECONNRESET' }))
|
let error = {
|
||||||
t.true(client.shouldRetry({ code: 'ENOTFOUND' }))
|
name: 'HTTPError',
|
||||||
t.true(client.shouldRetry({ code: 'ESOCKETTIMEDOUT' }))
|
response: {
|
||||||
t.true(client.shouldRetry({ code: 'ETIMEDOUT' }))
|
statusCode: 501,
|
||||||
t.true(client.shouldRetry({ code: 'ECONNREFUSED' }))
|
body: 'yikes',
|
||||||
t.true(client.shouldRetry({ code: 'EHOSTUNREACH' }))
|
},
|
||||||
t.true(client.shouldRetry({ code: 'EPIPE' }))
|
};
|
||||||
t.true(client.shouldRetry({ code: 'EAI_AGAIN' }))
|
t.is(client.parseErrorMessage(error), error);
|
||||||
t.false(client.shouldRetry({ code: 'ENOENT' }))
|
error = {
|
||||||
t.false(client.shouldRetry({ code: 'EACCES' }))
|
name: 'HTTPError',
|
||||||
t.false(client.shouldRetry({ code: 'EPERM' }))
|
response: {
|
||||||
})
|
statusCode: 500,
|
||||||
|
body: {},
|
||||||
test('rejects non-MusicBrainz errors', t => {
|
},
|
||||||
const client = new MusicBrainz({ baseURL: '$!@#$' })
|
};
|
||||||
t.throws(client.get('artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da'), Error)
|
t.is(client.parseErrorMessage(error), error);
|
||||||
})
|
error = {
|
||||||
|
name: 'HTTPError',
|
||||||
test('uses the default error impementation if there is no JSON error', t => {
|
response: {
|
||||||
t.is(client.parseErrorMessage({ statusCode: 501 }, 'yikes'), 'yikes')
|
statusCode: 404,
|
||||||
t.is(client.parseErrorMessage({ statusCode: 500 }, {}), '500')
|
body: null,
|
||||||
t.is(client.parseErrorMessage({ statusCode: 404 }, null), '404')
|
},
|
||||||
})
|
};
|
||||||
|
t.is(client.parseErrorMessage(error), error);
|
||||||
|
});
|
||||||
|
|
|
||||||
8
test/base-schema.js
Normal file
8
test/base-schema.js
Normal 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
13
test/extended-schema.js
Normal 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';
|
||||||
87
test/extensions/cover-art-archive/client.js
Normal file
87
test/extensions/cover-art-archive/client.js
Normal 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);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue