├── .editorconfig ├── .github └── workflows │ └── deno.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app.ts ├── cli.ts ├── config.ts ├── deno.json ├── harmonizer ├── deduplicate.ts ├── language_script.ts ├── merge.test.ts ├── merge.ts ├── properties.ts ├── release_types.test.ts ├── release_types.ts └── types.ts ├── lookup.ts ├── musicbrainz ├── api_client.ts ├── extract_mbid.ts ├── mbid_mapping.ts ├── release_countries.ts ├── seeding.ts ├── special_entities.ts └── type_id.ts ├── providers ├── Bandcamp │ ├── __snapshots__ │ │ └── mod.test.ts.snap │ ├── json_types.ts │ ├── mod.test.ts │ └── mod.ts ├── Beatport │ ├── json_types.ts │ ├── mod.test.ts │ └── mod.ts ├── Deezer │ ├── __snapshots__ │ │ └── mod.test.ts.snap │ ├── api_types.ts │ ├── mod.test.ts │ ├── mod.ts │ └── regions.ts ├── MusicBrainz │ └── mod.ts ├── Spotify │ ├── __snapshots__ │ │ └── mod.test.ts.snap │ ├── api_types.ts │ ├── mod.test.ts │ ├── mod.ts │ └── regions.ts ├── Tidal │ ├── __snapshots__ │ │ └── mod.test.ts.snap │ ├── mod.test.ts │ ├── mod.ts │ ├── regions.ts │ ├── v1 │ │ ├── api_types.ts │ │ └── lookup.ts │ └── v2 │ │ ├── api_types.ts │ │ └── lookup.ts ├── base.ts ├── features.ts ├── iTunes │ ├── __snapshots__ │ │ └── mod.test.ts.snap │ ├── api_types.ts │ ├── mod.test.ts │ ├── mod.ts │ └── regions.ts ├── mod.ts ├── registry.ts ├── template.ts ├── test_spec.ts └── test_stubs.ts ├── server ├── components │ ├── AlternativeValues.tsx │ ├── ArtistCredit.tsx │ ├── Button.tsx │ ├── CoverImage.tsx │ ├── Footer.tsx │ ├── ISRC.tsx │ ├── ISRCSubmission.tsx │ ├── InputWithOverlay.tsx │ ├── LinkWithMusicBrainz.tsx │ ├── LinkedEntity.tsx │ ├── MBIDInput.tsx │ ├── Markdown.tsx │ ├── MessageBox.tsx │ ├── NavigationBar.tsx │ ├── ProviderIcon.tsx │ ├── ProviderInput.tsx │ ├── ProviderList.tsx │ ├── Release.tsx │ ├── ReleaseLookup.tsx │ ├── SettingRow.tsx │ ├── SpriteIcon.tsx │ ├── TextWithLineBreaks.tsx │ ├── Tooltip.tsx │ └── Tracklist.tsx ├── dev.ts ├── fresh.gen.ts ├── icons │ ├── BrandBeatport.tsx │ ├── BrandIfpi.tsx │ ├── BrandMetaBrainz.tsx │ ├── BrandMetaBrainzFilled.tsx │ ├── beatport.svg │ ├── ifpi.svg │ └── metabrainz.svg ├── islands │ ├── PersistentInput.tsx │ ├── RegionList.tsx │ └── ReleaseSeeder.tsx ├── logging.ts ├── main.ts ├── permalink.ts ├── routes │ ├── _app.tsx │ ├── _middleware.ts │ ├── about.tsx │ ├── icon-sprite.svg.tsx │ ├── index.tsx │ ├── release.tsx │ ├── release │ │ └── actions.tsx │ └── settings.tsx ├── settings.ts ├── state.ts └── static │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── favicon.svg │ ├── harmony-logo-192.png │ ├── harmony-logo-512.png │ ├── harmony-logo-dark-bg.svg │ ├── harmony-logo-light-bg.svg │ ├── harmony-logo.svg │ ├── harmony.css │ └── site.webmanifest ├── testdata └── https! │ ├── com.apple.itunes │ └── lookup!entity=song&limit=200&id=1441458047&country=gb │ ├── com.bandcamp.thedarkthursday │ └── album │ │ └── and-it-was-a-burned-into-my-mind-yet-i-faltered-like-a-b#674cccc │ ├── com.deezer.api │ ├── album │ │ └── 629506181 │ └── track │ │ └── 2947516331 │ ├── com.spotify.api │ └── v1 │ │ ├── albums │ │ └── 10FLjwfpbxLmW8c25Xyc2N │ │ └── tracks!ids=2plbrEY59IikOBgBGLjaoe │ └── com.tidal.openapi │ └── albums │ ├── 130201923 │ └── items!countryCode=GB&limit=100&offset=0 │ └── 130201923!countryCode=GB └── utils ├── config.ts ├── cookie.ts ├── copyright.test.ts ├── copyright.ts ├── date.ts ├── errors.ts ├── fetch_stub.ts ├── file_path.test.ts ├── file_path.ts ├── gtin.test.ts ├── gtin.ts ├── html.ts ├── image.ts ├── label.test.ts ├── label.ts ├── locale.ts ├── plural.ts ├── predicate.ts ├── record.ts ├── regions.ts ├── script.ts ├── similarity.test.ts ├── similarity.ts ├── test_spec.ts ├── time.test.ts ├── time.ts └── tracklist.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = tab 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.github/workflows/deno.yml: -------------------------------------------------------------------------------- 1 | name: Deno 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | pull_request: 7 | branches: ['main'] 8 | workflow_dispatch: 9 | inputs: 10 | perform_deploy: 11 | type: boolean 12 | required: true 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Setup repo 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Deno 26 | uses: denoland/setup-deno@v2 27 | with: 28 | deno-version: v2.1.x 29 | 30 | - name: Verify formatting 31 | run: deno fmt --check 32 | 33 | - name: Run linter 34 | run: deno lint 35 | 36 | - name: Run type check 37 | run: deno task check 38 | 39 | - name: Run tests 40 | # CI tests should run offline and only request minimal permissions 41 | run: deno test --deny-net --allow-read --allow-env 42 | 43 | deploy: 44 | if: >- 45 | vars.DEPLOY_ENABLED == 'true' && 46 | contains(fromJSON(vars.DEPLOYERS), github.actor) && 47 | (startsWith(github.ref, 'refs/tags/v') || inputs.perform_deploy) 48 | runs-on: ubuntu-latest 49 | needs: test 50 | 51 | env: 52 | WORK_DIR: harmony 53 | 54 | steps: 55 | - name: Setup repo 56 | uses: actions/checkout@v4 57 | with: 58 | path: ${{ env.WORK_DIR }} 59 | 60 | - name: Deploy 61 | uses: easingthemes/ssh-deploy@v5.0.3 62 | with: 63 | SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_KEY }} 64 | ARGS: '-cilrvz --delete' 65 | SOURCE: ${{ env.WORK_DIR }}/ 66 | REMOTE_HOST: ${{ secrets.DEPLOY_HOST }} 67 | REMOTE_PORT: ${{ secrets.DEPLOY_PORT || 22 }} 68 | REMOTE_USER: ${{ secrets.DEPLOY_USER }} 69 | TARGET: ${{ secrets.DEPLOY_TARGET }} 70 | EXCLUDE: '/deno.lock, /.env, /snaps.db, /snaps/' 71 | SCRIPT_BEFORE_REQUIRED: true 72 | SCRIPT_BEFORE: | 73 | set -e 74 | ls -al "${{ secrets.DEPLOY_TARGET }}" 75 | SCRIPT_AFTER_REQUIRED: true 76 | SCRIPT_AFTER: | 77 | set -e 78 | ls -al "${{ secrets.DEPLOY_TARGET }}" 79 | cd "${{ secrets.DEPLOY_TARGET }}" 80 | deno task build 81 | if [[ -n "${{ secrets.DEPLOY_SERVICE }}" ]]; then 82 | systemctl --user restart "${{ secrets.DEPLOY_SERVICE }}" 83 | fi 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Deno 2 | deno.lock 3 | 4 | # Fresh build directory 5 | _fresh/ 6 | 7 | # dotenv environment variable files 8 | .env 9 | 10 | # Harmony snapshots 11 | snaps.db 12 | snaps/ 13 | 14 | # Temporary files 15 | local/ 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "allmusic", 4 | "Audiobook", 5 | "Bandcamp", 6 | "Beatport", 7 | "brainz", 8 | "Brainz", 9 | "deezer", 10 | "Deezer", 11 | "Deno", 12 | "discogs", 13 | "Discogs", 14 | "gcal", 15 | "Grek", 16 | "gtin", 17 | "GTIN", 18 | "gtins", 19 | "Hani", 20 | "Hebr", 21 | "Hira", 22 | "ical", 23 | "isrc", 24 | "ISRC", 25 | "itunes", 26 | "Jpan", 27 | "Kore", 28 | "lande", 29 | "mbid", 30 | "MBID", 31 | "mbids", 32 | "Mbids", 33 | "metabrainz", 34 | "millis", 35 | "musicbrainz", 36 | "mzstatic", 37 | "nums", 38 | "preact", 39 | "preorder", 40 | "runtimes", 41 | "secondhandsongs", 42 | "smartradio", 43 | "spotify", 44 | "streamable", 45 | "tabler", 46 | "trackinfo", 47 | "tracklist", 48 | "tracknum", 49 | "tralbum", 50 | "vgmdb" 51 | ], 52 | "deno.enable": true, 53 | "deno.lint": true, 54 | "[typescript]": { 55 | "editor.defaultFormatter": "denoland.vscode-deno" 56 | }, 57 | "[typescriptreact]": { 58 | "editor.defaultFormatter": "denoland.vscode-deno" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute to Harmony 2 | 3 | Your contributions are welcome, be it code, documentation or feedback. 4 | 5 | If you want to contribute a bigger feature, please open a discussion first to be sure that your idea will be accepted. 6 | 7 | ## Code Submission 8 | 9 | Before submitting your changes, please make sure that they are properly formatted and pass the linting rules and type checking: 10 | 11 | ```sh 12 | deno fmt --check 13 | deno lint 14 | deno task check 15 | ``` 16 | 17 | There is also a Deno task which combines the previous commands: 18 | 19 | ```sh 20 | deno task ok 21 | ``` 22 | 23 | ## Testing 24 | 25 | When you have changed existing code you should run the relevant tests with `deno test`. 26 | Most tests do not require special permissions, only provider tests need read access (`-R`) to testdata and to environment variables (`-E`): 27 | 28 | ```sh 29 | deno test -RE providers/ 30 | ``` 31 | 32 | Automated tests should not perform any **network requests** by default, which means that the affected functions have to be stubbed. 33 | If the [provided stubbing helpers](providers/test_stubs.ts) are used, tests try to read the response from the `testdata` directory instead. 34 | 35 | When you create a new test which needs data from the network, the missing response has to be cached first. 36 | This happens when you run the new test (let us call it `new.test.ts`) in _download mode_ once. 37 | It is enabled by passing the `--download` flag to the test (note the `--` separator between Deno arguments and arguments for the test itself): 38 | 39 | ```sh 40 | deno test --allow-net --allow-write new.test.ts -- --download 41 | ``` 42 | 43 | There exists a **provider test framework** with which URL handling and release lookup tests can be described without boilerplate. 44 | This makes provider specifications mostly declarative, the developer only has to write a few assertions for each looked up release. 45 | 46 | Often release data is compared against a reference [snapshot] from a `.snap` file in order to catch unexpected changes. 47 | You can create new snapshots or update them by passing the `--update` flag to the test. 48 | 49 | [snapshot]: https://jsr.io/@std/testing/doc/snapshot 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2024 David Kellner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Harmony 2 | 3 | > Music Metadata Aggregator and MusicBrainz Importer 4 | 5 | ## Features 6 | 7 | - Lookup of release metadata from multiple sources by URL and/or GTIN 8 | - Metadata providers convert source data into a common, harmonized representation 9 | - Additional sources can be supported by adding more provider implementations 10 | - Merging of harmonized metadata from your preferred providers 11 | - Seeding of [MusicBrainz] releases using the merged metadata 12 | - Resolving of external entity identifiers to MBIDs 13 | - Automatic guessing of title language and script 14 | - Permalinks which load snapshots of the originally queried source data 15 | 16 | ## Usage 17 | 18 | Most modules of this TypeScript codebase use web standards and should be able to run in modern browsers and other JavaScript runtimes. 19 | Only the [Fresh] server app and the CLI were written specifically for [Deno]. 20 | 21 | The following instructions assume that you have the latest Deno version installed. 22 | 23 | You can start a local development server with the following command: 24 | 25 | ```sh 26 | deno task dev 27 | ``` 28 | 29 | You can now open the logged URL in your browser to view the landing page. 30 | Try doing some code changes and see how the page automatically reloads. 31 | 32 | For a production server you should set the `PORT` environment variable to your preferred port and `DENO_DEPLOYMENT_ID` to the current git revision (commit hash or tag name). 33 | Alternatively you can run the [predefined task](deno.json) which automatically sets `DENO_DEPLOYMENT_ID` and runs `server/main.ts` with all permissions: 34 | 35 | ```sh 36 | deno task server 37 | ``` 38 | 39 | Other environment variables which are used by the server are documented in the [configuration module](server/config.ts). 40 | 41 | There is also a small command line app which can be used for testing: 42 | 43 | ```sh 44 | deno task cli 45 | ``` 46 | 47 | [Deno]: https://deno.com 48 | [Fresh]: https://fresh.deno.dev 49 | [MusicBrainz]: https://musicbrainz.org 50 | 51 | ## Architecture 52 | 53 | The entire code is written in TypeScript, the components of the web interface additionally use JSX syntax. 54 | 55 | A brief explanation of the directory structure should give you a basic idea how Harmony is working: 56 | 57 | - `harmonizer/`: Harmonized source data representation and algorithms 58 | - `types.ts`: Type definitions of harmonized releases (and other entities) 59 | - `merge.ts`: Merge algorithm for harmonized releases (from multiple sources) 60 | - `providers/`: Metadata provider implementations, one per subfolder 61 | - `base.ts`: Abstract base classes from which all providers inherit 62 | - `registry.ts`: Registry which manages all supported providers, instantiated in `mod.ts` 63 | - `lookup.ts`: Combined release lookup which accepts GTIN, URLs and/or IDs for any supported provider from the registry 64 | - `musicbrainz/`: MusicBrainz specific code 65 | - `seeding.ts`: Release editor seeding 66 | - `mbid_mapping.ts`: Resolving of external IDs/URLs to MBIDs 67 | - `server/`: Web app to lookup releases and import them into MusicBrainz 68 | - `routes/`: Request handlers of the [Fresh] server (file-based routing) 69 | - `static/`: Static files which will be served 70 | - `components/`: Static [Preact] components which will be rendered as HTML by the server 71 | - `islands/`: Dynamic [Preact] components which will be re-rendered by the client 72 | - `utils/`: Various utility functions 73 | 74 | Let us see what happens if someone looks up a release using the website: 75 | 76 | 1. The Fresh app handles the request to the `/release` route in `server/routes/release.tsx`. 77 | 2. A combined release lookup is initiated, which finds the matching provider(s) in the registry and calls their release lookup methods. 78 | 3. Each requested provider fetches the release data and converts it into a harmonized release. 79 | 4. Once all requested providers have been looked up, the individual release are combined into one release using the merge algorithm. 80 | 5. The route handler calls the MBID mapper, handles errors and renders the release page, including a hidden release seeder form. 81 | 6. In order to create the release seed, the harmonized release is converted into the format expected by MusicBrainz where some data can only be put into the annotation. 82 | 83 | All requests which are initiated by a provider will be cached by the base class using [snap_storage] (persisted in `snaps.db` and a `snaps/` folder). 84 | Each snapshot contains the response body and can be accessed by request URL and a timestamp condition. 85 | This allows edit notes to contain permalinks which encode a timestamp and the necessary info to initiate the same lookup again, now with the underlying requests being cached. 86 | 87 | [Preact]: https://preactjs.com/ 88 | [snap_storage]: https://github.com/kellnerd/snap_storage 89 | 90 | ## Contributing 91 | 92 | Your contributions are welcome, be it code, documentation or feedback. 93 | 94 | If you want to contribute, please have a look at the hints in the [contribution guidelines](CONTRIBUTING.md). 95 | -------------------------------------------------------------------------------- /app.ts: -------------------------------------------------------------------------------- 1 | import { codeUrl, shortRevision } from './config.ts'; 2 | 3 | /** Information about the application. */ 4 | export interface AppInfo { 5 | /** Name of the application. */ 6 | name: string; 7 | /** Version of the application. */ 8 | version: string; 9 | /** Contact URL for the application. */ 10 | contact?: string; 11 | } 12 | 13 | /** App information about Harmony. */ 14 | export const appInfo: AppInfo = { 15 | name: 'Harmony', 16 | version: shortRevision, 17 | contact: codeUrl.href, 18 | }; 19 | -------------------------------------------------------------------------------- /cli.ts: -------------------------------------------------------------------------------- 1 | import { getMergedReleaseByGTIN, getReleaseByUrl } from '@/lookup.ts'; 2 | import { createReleaseSeed } from '@/musicbrainz/seeding.ts'; 3 | import { parse } from 'std/flags/mod.ts'; 4 | 5 | import type { GTIN, HarmonyRelease } from './harmonizer/types.ts'; 6 | 7 | const args = parse(Deno.args, { 8 | boolean: ['isrc', 'multi-disc', 'seed'], 9 | string: '_', // do not parse numeric positional arguments 10 | }); 11 | 12 | if (args._.length === 1) { 13 | let specifier: GTIN | URL = args._[0]; 14 | 15 | try { 16 | specifier = new URL(specifier); 17 | } catch { 18 | // not a valid URL, treat specifier as GTIN 19 | } 20 | 21 | const releaseOptions = { 22 | withISRC: args['isrc'], 23 | withSeparateMedia: args['multi-disc'], 24 | }; 25 | 26 | let release: HarmonyRelease; 27 | 28 | if (specifier instanceof URL) { 29 | release = await getReleaseByUrl(specifier, releaseOptions); 30 | } else { 31 | release = await getMergedReleaseByGTIN(specifier, releaseOptions); 32 | } 33 | 34 | if (args.seed) { 35 | console.log(createReleaseSeed(release, { projectUrl: new URL('http://example.com') })); 36 | } else { 37 | console.log(JSON.stringify(release)); 38 | } 39 | } else { 40 | console.info('Usage: deno task cli [--isrc] [--multi-disc] [--seed]'); 41 | } 42 | -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | import { getBooleanFromEnv, getFromEnv, getUrlFromEnv } from '@/utils/config.ts'; 2 | import { join } from 'std/url/join.ts'; 3 | 4 | /** Source code URL. */ 5 | export const codeUrl = getUrlFromEnv('HARMONY_CODE_URL', 'https://github.com/kellnerd/harmony'); 6 | 7 | /** User support URL. */ 8 | export const supportUrl = getUrlFromEnv('HARMONY_SUPPORT_URL', join(codeUrl, 'issues')); 9 | 10 | /** Base URL of the MusicBrainz API which should be used to request data. */ 11 | export const musicbrainzApiBaseUrl = getUrlFromEnv('HARMONY_MB_API_URL', 'https://musicbrainz.org/ws/2/'); 12 | 13 | /** Base URL of the MusicBrainz server which should be targeted (by links and for seeding). */ 14 | export const musicbrainzTargetServer = getUrlFromEnv('HARMONY_MB_TARGET_URL', 'https://musicbrainz.org/'); 15 | 16 | /** Current git revision of the app. */ 17 | export const revision = getFromEnv('DENO_DEPLOYMENT_ID'); 18 | 19 | /** Current git revision, shortened if it is a hash, or "unknown". */ 20 | export const shortRevision = revision 21 | ? (/^[0-9a-f]+$/.test(revision) ? revision.substring(0, 7) : revision) 22 | : 'unknown'; 23 | 24 | /** Source code URL for the current git revision. */ 25 | export const codeRevisionUrl = (revision && codeUrl.hostname === 'github.com') 26 | ? join(codeUrl, 'tree', revision) 27 | : undefined; 28 | 29 | /** Indicates whether the current app runs in development mode. */ 30 | export const inDevMode = !revision; 31 | 32 | /** Path to the directory where the app should persist data like snapshots. */ 33 | export const dataDir = getFromEnv('HARMONY_DATA_DIR') || '.'; 34 | 35 | /** Indicates whether the protocol of a client from the `X-Forwarded-Proto` proxy header should be used. */ 36 | export const forwardProto = getBooleanFromEnv('FORWARD_PROTO'); 37 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "jsxImportSource": "preact" 5 | }, 6 | "fmt": { 7 | "lineWidth": 120, 8 | "proseWrap": "preserve", 9 | "singleQuote": true, 10 | "useTabs": true 11 | }, 12 | "imports": { 13 | "@/": "./", 14 | "@deno/gfm": "jsr:@deno/gfm@^0.8.0", 15 | "@kellnerd/musicbrainz": "jsr:@kellnerd/musicbrainz@^0.3.0", 16 | "@std/collections": "jsr:@std/collections@^1.0.10", 17 | "@std/path": "jsr:@std/path@^1.0.8", 18 | "@std/testing": "jsr:@std/testing@^1.0.9", 19 | "@std/uuid": "jsr:@std/uuid@^1.0.6", 20 | "$fresh/": "https://deno.land/x/fresh@1.6.8/", 21 | "fresh/": "https://deno.land/x/fresh@1.6.8/", 22 | "lande": "https://esm.sh/lande@1.0.10", 23 | "preact": "https://esm.sh/preact@10.19.6", 24 | "preact/": "https://esm.sh/preact@10.19.6/", 25 | "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2", 26 | "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", 27 | "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", 28 | "snap-storage": "https://deno.land/x/snap_storage@v0.6.4/mod.ts", 29 | "std/": "https://deno.land/std@0.216.0/", 30 | "tabler-icons/": "https://deno.land/x/tabler_icons_tsx@0.0.6/tsx/", 31 | "ts-custom-error": "https://esm.sh/ts-custom-error@3.3.1", 32 | "utils/": "https://deno.land/x/es_utils@v0.2.1/" 33 | }, 34 | "tasks": { 35 | "check": "deno check server/fresh.gen.ts cli.ts", 36 | "ok": "deno fmt --check && deno lint && deno task check", 37 | "cli": "deno run --allow-net --allow-read=. --allow-write=. cli.ts", 38 | "dev": "deno run -A --watch=server/static/,server/routes/ server/dev.ts", 39 | "build": "deno run -A server/dev.ts build", 40 | "server": "DENO_DEPLOYMENT_ID=$(git describe --tags) deno run -A server/main.ts" 41 | }, 42 | "lint": { 43 | "rules": { 44 | "tags": ["fresh", "recommended"] 45 | } 46 | }, 47 | "exclude": ["**/_fresh/*"] 48 | } 49 | -------------------------------------------------------------------------------- /harmonizer/deduplicate.ts: -------------------------------------------------------------------------------- 1 | import { ResolvableEntity } from '@/harmonizer/types.ts'; 2 | 3 | /** Deduplicates the given entities based on MBID and name. */ 4 | export function deduplicateEntities(entities: Iterable): T[] { 5 | const result: T[] = []; 6 | const mbids = new Set(); 7 | const names = new Set(); 8 | 9 | for (const entity of entities) { 10 | const { name, mbid } = entity; 11 | if (mbid) { 12 | if (!mbids.has(mbid)) { 13 | result.push(entity); 14 | mbids.add(mbid); 15 | } 16 | } else if (name) { 17 | if (!names.has(name)) { 18 | result.push(entity); 19 | names.add(name); 20 | } 21 | } 22 | } 23 | 24 | return result; 25 | } 26 | -------------------------------------------------------------------------------- /harmonizer/language_script.ts: -------------------------------------------------------------------------------- 1 | import type { HarmonyRelease } from '@/harmonizer/types.ts'; 2 | import { formatLanguageConfidence, formatScriptFrequency } from '@/utils/locale.ts'; 3 | import { detectScripts, scriptCodes } from '@/utils/script.ts'; 4 | import lande from 'lande'; 5 | 6 | /** Detects the script and guesses the language of the given release (if missing). */ 7 | export function detectLanguageAndScript(release: HarmonyRelease): void { 8 | const allTitles = release.media.flatMap((medium) => medium.tracklist.map((track) => track.title)); 9 | allTitles.push(release.title); 10 | const textInput = allTitles.join('\n'); 11 | const letters = textInput.replaceAll(/\P{Letter}/gu, ''); 12 | 13 | if (!letters.length) { 14 | release.info.messages.push({ 15 | type: 'debug', 16 | text: 'Titles contain no letters in any script', 17 | }); 18 | 19 | // Set language to [No linguistic content] and exit early. 20 | release.language = { 21 | code: 'zxx', 22 | }; 23 | return; 24 | } 25 | 26 | if (!release.script) { 27 | const scripts = detectScripts(textInput, scriptCodes); 28 | 29 | if (scripts.length) { 30 | const mainScript = scripts[0]; 31 | 32 | release.info.messages.push({ 33 | type: 'debug', 34 | text: `Detected scripts of the titles: ${scripts.map(formatScriptFrequency).join(', ')}`, 35 | }); 36 | 37 | if (mainScript.frequency > 0.7) { 38 | release.script = mainScript; 39 | } 40 | } else { 41 | release.info.messages.push({ 42 | type: 'warning', 43 | text: 'Titles are written in an unsupported script, please report', 44 | }); 45 | } 46 | } 47 | 48 | // Guesses for single track releases are wrong more often than not, skip them. 49 | if (!release.language && allTitles.length > 2) { 50 | const guessedLanguages = lande(textInput); 51 | const topLanguage = guessedLanguages[0]; 52 | 53 | const formattedList = guessedLanguages 54 | .map(([code, confidence]) => ({ code, confidence })) 55 | .filter(({ confidence }) => confidence > 0.1) 56 | .map(formatLanguageConfidence); 57 | release.info.messages.push({ 58 | type: 'debug', 59 | text: `Guessed language of the titles: ${formattedList.join(', ')}`, 60 | }); 61 | 62 | if (topLanguage[1] > 0.8) { 63 | release.language = { 64 | code: topLanguage[0], 65 | confidence: topLanguage[1], 66 | }; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /harmonizer/merge.test.ts: -------------------------------------------------------------------------------- 1 | import { mergeResolvableEntityArray, mergeSortedResolvableEntityArray } from './merge.ts'; 2 | import type { ResolvableEntity } from '@/harmonizer/types.ts'; 3 | 4 | import { describe, it } from '@std/testing/bdd'; 5 | import { assertEquals } from 'std/assert/assert_equals.ts'; 6 | 7 | function fakeEntity(name: string, ...providers: string[]): ResolvableEntity { 8 | return { 9 | name, 10 | externalIds: providers.map((provider) => ({ provider, type: 'entity', id: provider })), 11 | }; 12 | } 13 | 14 | type EntityParams = Parameters; 15 | 16 | const sortedEntityArrayCases: Array<[string, EntityParams[], EntityParams[][], EntityParams[]]> = [ 17 | ['one entity (same name)', [['Test', 'A']], [ 18 | [['Test', 'B']], 19 | [['Test', 'C']], 20 | ], [['Test', 'A', 'B', 'C']]], 21 | ['two entities (same names, same order)', [['Jane', 'A1'], ['John', 'A2']], [ 22 | [['Jane', 'B1'], ['John', 'B2']], 23 | ], [['Jane', 'A1', 'B1'], ['John', 'A2', 'B2']]], 24 | ['multiple entities (similar names, same order)', [['Jané', 'A1'], ['John', 'A2'], ['T.E.S.T.', 'A3']], [ 25 | [['Jane', 'B1'], ['Jöhn', 'B2'], ['TEST']], 26 | [['Ja-ne', 'C1'], ['"John"'], ['test', 'C3']], 27 | ], [['Jané', 'A1', 'B1', 'C1'], ['John', 'A2', 'B2'], ['T.E.S.T.', 'A3', 'C3']]], 28 | ['one entity (same name) with duplicate identifiers', [['Test', 'A']], [ 29 | [['Test', 'A']], 30 | [['Test', 'B', 'B']], 31 | ], [['Test', 'A', 'B']]], 32 | ]; 33 | 34 | const unorderedEntityArrayCases: Array<[string, EntityParams[], EntityParams[][], EntityParams[]]> = [ 35 | ['two entities (same names, swapped order)', [['Jane', 'A1'], ['John', 'A2']], [ 36 | [['John', 'B2'], ['Jane', 'B1']], 37 | ], [['Jane', 'A1', 'B1'], ['John', 'A2', 'B2']]], 38 | ]; 39 | 40 | describe('mergeSortedResolvableEntityArray', () => { 41 | sortedEntityArrayCases.forEach(([description, target, sources, expected]) => { 42 | it(`merges identifiers of ${description}`, () => { 43 | const targetEntityArray = target.map((params) => fakeEntity(...params)); 44 | const expectedTargetEntityArray = expected.map((params) => fakeEntity(...params)); 45 | const sourceEntityArrays = sources.map((paramsArray) => paramsArray.map((params) => fakeEntity(...params))); 46 | 47 | mergeSortedResolvableEntityArray(targetEntityArray, sourceEntityArrays); 48 | assertEquals(targetEntityArray, expectedTargetEntityArray); 49 | }); 50 | }); 51 | 52 | // Proves that deduplication by reference works (which is sufficient for now). 53 | it('merges identifiers without duplicates when the target entity is among the sources', () => { 54 | const targetEntityArray = [fakeEntity('Test', 'A')]; 55 | const sourceEntityArrays = [targetEntityArray, [fakeEntity('Test', 'B')]]; 56 | const expectedTargetEntityArray = [fakeEntity('Test', 'A', 'B')]; 57 | 58 | mergeSortedResolvableEntityArray(targetEntityArray, sourceEntityArrays); 59 | assertEquals(targetEntityArray, expectedTargetEntityArray); 60 | }); 61 | 62 | unorderedEntityArrayCases.forEach(([description, target, sources]) => { 63 | it(`fails to merge identifiers of ${description}`, () => { 64 | const targetEntityArray = target.map((params) => fakeEntity(...params)); 65 | const expectedTargetEntityArray = target.map((params) => fakeEntity(...params)); 66 | const sourceEntityArrays = sources.map((paramsArray) => paramsArray.map((params) => fakeEntity(...params))); 67 | 68 | mergeSortedResolvableEntityArray(targetEntityArray, sourceEntityArrays); 69 | assertEquals(targetEntityArray, expectedTargetEntityArray); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('mergeResolvableEntityArray', () => { 75 | sortedEntityArrayCases.concat(unorderedEntityArrayCases).forEach(([description, target, sources, expected]) => { 76 | it(`merges identifiers of ${description}`, () => { 77 | const targetEntityArray = target.map((params) => fakeEntity(...params)); 78 | const expectedTargetEntityArray = expected.map((params) => fakeEntity(...params)); 79 | const sourceEntityArrays = sources.map((paramsArray) => paramsArray.map((params) => fakeEntity(...params))); 80 | 81 | mergeResolvableEntityArray(targetEntityArray, sourceEntityArrays); 82 | assertEquals(targetEntityArray, expectedTargetEntityArray); 83 | }); 84 | }); 85 | 86 | it('merges identifiers without duplicates when the target entity is among the sources', () => { 87 | const targetEntityArray = [fakeEntity('Test', 'A')]; 88 | const sourceEntityArrays = [targetEntityArray, [fakeEntity('Test', 'B')]]; 89 | const expectedTargetEntityArray = [fakeEntity('Test', 'A', 'B')]; 90 | 91 | mergeResolvableEntityArray(targetEntityArray, sourceEntityArrays); 92 | assertEquals(targetEntityArray, expectedTargetEntityArray); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /harmonizer/properties.ts: -------------------------------------------------------------------------------- 1 | import type { HarmonyRelease } from './types.ts'; 2 | 3 | /** Release properties which can be combined from data of multiple providers. */ 4 | export const combinableReleaseProperties: Array = [ 5 | 'externalLinks', 6 | 'availableIn', 7 | 'excludedFrom', 8 | 'info', 9 | ]; 10 | 11 | /** 12 | * Release properties which have to be taken from one provider and can not be combined from data of multiple providers. 13 | * Except for `title` (mandatory), `artists` and `media` (array) these are optional and might be missing for a provider. 14 | */ 15 | export const immutableReleaseProperties = [ 16 | 'title', 17 | 'artists', 18 | 'releaseGroup', 19 | 'gtin', // TODO: has to be missing or identical during merge 20 | 'media', // TODO: has to be missing or of identical shape during merge 21 | 'language', 22 | 'script', 23 | 'status', 24 | 'releaseDate', 25 | 'labels', 26 | 'packaging', 27 | 'images', // TODO: make images combinable? combine if not only front covers? 28 | 'copyright', 29 | 'credits', 30 | ] as const; 31 | 32 | /** Track properties which have to be taken from one provider and can not be combined from data of multiple providers. */ 33 | export const immutableTrackProperties = [ 34 | 'isrc', 35 | 'length', 36 | 'recording', 37 | ] as const; 38 | -------------------------------------------------------------------------------- /harmonizer/release_types.ts: -------------------------------------------------------------------------------- 1 | import { HarmonyRelease, HarmonyTrack, ReleaseGroupType } from './types.ts'; 2 | import { primaryTypeIds } from '@kellnerd/musicbrainz/data/release-group'; 3 | 4 | /** Guess the types for a release from release and track titles. */ 5 | export function guessTypesForRelease(release: HarmonyRelease): Iterable { 6 | let types = new Set(release.types); 7 | types = types.union(guessTypesFromTitle(release.title)); 8 | if (!types.has('Live') && guessLiveRelease(release.media.flatMap((media) => media.tracklist))) { 9 | types.add('Live'); 10 | } 11 | return types; 12 | } 13 | 14 | const detectTypesPatterns = [ 15 | // Commonly used for Bandcamp releases 16 | /\s\((EP|Single|Live|Demo)\)(?:\s\(.*?\))?$/i, 17 | // iTunes singles and EPs 18 | /\s- (EP|Single|Live)(?:\s\(.*?\))?$/i, 19 | // Generic "EP" suffix 20 | /\s(EP)(?:\s\(.*?\))?$/i, 21 | ]; 22 | 23 | /** Guesses a release type from a title. */ 24 | export function guessTypesFromTitle(title: string): Set { 25 | const types = new Set(); 26 | detectTypesPatterns.forEach((pattern) => { 27 | const match = title.match(pattern); 28 | if (match) { 29 | types.add(capitalizeReleaseType(match[1])); 30 | } 31 | }); 32 | return types; 33 | } 34 | 35 | /** Returns true if all track titles indicate a live release. */ 36 | export function guessLiveRelease(tracks: HarmonyTrack[]): boolean { 37 | return tracks?.length > 0 && tracks.every((track) => { 38 | const types = guessTypesFromTitle(track.title); 39 | return types.has('Live'); 40 | }); 41 | } 42 | 43 | /** Takes a release type as a string and turns it into a [ReleaseGroupType]. */ 44 | export function capitalizeReleaseType(sourceType: string): ReleaseGroupType { 45 | const type = sourceType.toLowerCase(); 46 | switch (type) { 47 | case 'ep': 48 | return 'EP'; 49 | case 'dj-mix': 50 | return 'DJ-mix'; 51 | case 'mixtape/street': 52 | return 'Mixtape/Street'; 53 | default: 54 | return type.charAt(0).toUpperCase() + type.slice(1) as ReleaseGroupType; 55 | } 56 | } 57 | 58 | /** Returns a new array with the types sorted, primary types first and secondary types second. */ 59 | export function sortTypes(types: Iterable): ReleaseGroupType[] { 60 | return Array.from(types).sort((a, b) => { 61 | if (a == b) { 62 | return 0; 63 | } 64 | 65 | const primaryA = isPrimaryType(a); 66 | const primaryB = isPrimaryType(b); 67 | 68 | if (primaryA && !primaryB) { 69 | return -1; 70 | } else if (!primaryA && primaryB) { 71 | return 1; 72 | } else { 73 | return a > b ? 1 : -1; 74 | } 75 | }); 76 | } 77 | 78 | /** Takes several lists of types and returns a single array of unique, sorted types. 79 | * 80 | * The result is reduced to unique elements with only a single primary type. 81 | */ 82 | export function mergeTypes(...typeLists: Iterable[]): ReleaseGroupType[] { 83 | const primaryTypes = new Set(); 84 | const resultTypes = new Set(); 85 | for (const types of typeLists) { 86 | for (const type of types) { 87 | if (isPrimaryType(type)) { 88 | primaryTypes.add(type); 89 | } else { 90 | resultTypes.add(type); 91 | } 92 | } 93 | } 94 | if (primaryTypes.size) { 95 | resultTypes.add(reducePrimaryTypes(Array.from(primaryTypes))); 96 | } 97 | return sortTypes(resultTypes); 98 | } 99 | 100 | const primaryTypes = Object.keys(primaryTypeIds); 101 | 102 | function isPrimaryType(type: ReleaseGroupType): boolean { 103 | return primaryTypes.includes(type); 104 | } 105 | 106 | /** Reduce a list of primary types to a single type. 107 | */ 108 | function reducePrimaryTypes(types: Array): ReleaseGroupType { 109 | return types.reduce((previous, current) => { 110 | if (previous == 'Album' || previous == 'Other') { 111 | // Prefer more specific types over Album or Other. Many providers use Album 112 | // as the generic type. 113 | return current; 114 | } else if (previous == 'Single' && current == 'EP') { 115 | // Prefer EP over Single 116 | return current; 117 | } 118 | 119 | // No specific preference, just use the first type found. 120 | return previous; 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /musicbrainz/api_client.ts: -------------------------------------------------------------------------------- 1 | import { appInfo } from '@/app.ts'; 2 | import { musicbrainzApiBaseUrl } from '@/config.ts'; 3 | import { MusicBrainzClient } from '@kellnerd/musicbrainz'; 4 | 5 | export const MB = new MusicBrainzClient({ 6 | apiUrl: musicbrainzApiBaseUrl.href, 7 | app: appInfo, 8 | maxQueueSize: 20, 9 | }); 10 | -------------------------------------------------------------------------------- /musicbrainz/extract_mbid.ts: -------------------------------------------------------------------------------- 1 | import { type EntityType, entityTypes } from '@kellnerd/musicbrainz/data/entity'; 2 | import { assert } from 'std/assert/assert.ts'; 3 | import { validate } from '@std/uuid/v4'; 4 | 5 | const MBID_LENGTH = 36; 6 | 7 | /** 8 | * Extracts an MBID from the given input. 9 | * 10 | * If the input is an URL, the entity type will be validated against the allowed types (which default to all types). 11 | */ 12 | export function extractMBID(input: string, allowedTypes: readonly EntityType[] = entityTypes): string { 13 | input = input.trim(); 14 | if (input.length === MBID_LENGTH) { 15 | assert(validate(input), `"${input}" is not a valid MBID`); 16 | return input; 17 | } else { 18 | const entityUrlPattern = new RegExp(`(${allowedTypes.join('|')})/([0-9a-f-]{36})(?:$|/|\\?)`); 19 | const entity = input.match(entityUrlPattern); 20 | assert(entity, `"${input}" does not contain a valid ${allowedTypes.join('/')} MBID`); 21 | return entity[2]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /musicbrainz/release_countries.ts: -------------------------------------------------------------------------------- 1 | import type { CountryCode, HarmonyRelease } from '@/harmonizer/types.ts'; 2 | 3 | export function determineReleaseEventCountries(release: HarmonyRelease, maxEvents = 10): CountryCode[] | undefined { 4 | const { availableIn, excludedFrom } = release; 5 | const worldwide = 'XW'; 6 | if (!availableIn) { 7 | return; 8 | } else if (availableIn.includes(worldwide)) { 9 | return [worldwide]; 10 | } else if (availableIn.length <= maxEvents) { 11 | return availableIn; 12 | } else if (!isImportantRegionExcluded(excludedFrom)) { 13 | return [worldwide]; 14 | } 15 | } 16 | 17 | function isImportantRegionExcluded(excludedRegions?: CountryCode[]): boolean { 18 | if (!excludedRegions?.length) return false; 19 | if (excludedRegions.length > ignoredExcludedRegions.size) return true; 20 | return excludedRegions.some((region) => !ignoredExcludedRegions.has(region)); 21 | } 22 | 23 | const uninhabitedRegions = [ 24 | 'IO', // British Indian Ocean Territory 25 | ]; 26 | 27 | const dependentTerritories = [ 28 | 'CC', // Cocos (Keeling) Islands -> Australia 29 | 'CK', // Cook Islands -> New Zealand 30 | 'CX', // Christmas Island -> Australia 31 | 'FK', // Falkland Islands -> Great Britain 32 | 'NF', // Norfolk Island -> Australia 33 | 'NU', // Niue -> New Zealand 34 | 'PR', // Puerto Rico -> United States 35 | 'SJ', // Svalbard and Jan Mayen -> Norway 36 | 'TK', // Tokelau -> New Zealand 37 | ]; 38 | 39 | const boycottedRegions = [ 40 | 'BY', // Belarus 41 | 'RU', // Russia 42 | ]; 43 | 44 | const ignoredExcludedRegions = new Set([ 45 | ...uninhabitedRegions, 46 | ...dependentTerritories, 47 | ...boycottedRegions, 48 | ]); 49 | -------------------------------------------------------------------------------- /musicbrainz/special_entities.ts: -------------------------------------------------------------------------------- 1 | import { ArtistCreditName, Label } from '@/harmonizer/types.ts'; 2 | 3 | export const noArtist: ArtistCreditName = { 4 | name: '[no artist]', 5 | mbid: 'eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61', 6 | }; 7 | 8 | export const unknownArtist: ArtistCreditName = { 9 | name: '[unknown]', 10 | mbid: '125ec42a-7229-4250-afc5-e057484327fe', 11 | }; 12 | 13 | export const variousArtists: ArtistCreditName = { 14 | name: 'Various Artists', 15 | mbid: '89ad4ac3-39f7-470e-963a-56509c546377', 16 | }; 17 | 18 | export const noLabel: Label = { 19 | name: '[no label]', 20 | mbid: '157afde4-4bf5-4039-8ad2-5a15acc85176', 21 | }; 22 | -------------------------------------------------------------------------------- /musicbrainz/type_id.ts: -------------------------------------------------------------------------------- 1 | // TODO: Move into @kellnerd/musicbrainz/data/link_type.ts 2 | 3 | import type { EntityType } from '@kellnerd/musicbrainz/data/entity'; 4 | 5 | export const artistUrlTypeIds = { 6 | 'discography': 171, 7 | 'fanpage': 172, 8 | 'image': 173, 9 | 'purevolume': 174, 10 | 'purchase for mail-order': 175, 11 | 'purchase for download': 176, 12 | 'download for free': 177, 13 | 'IMDb': 178, 14 | 'wikipedia': 179, 15 | 'discogs': 180, 16 | 'biography': 182, 17 | 'official homepage': 183, 18 | 'discography page': 184, 19 | 'online community': 185, 20 | 'get the music': 187, 21 | 'other databases': 188, 22 | 'myspace': 189, 23 | 'vgmdb': 191, 24 | 'social network': 192, 25 | 'youtube': 193, 26 | 'free streaming': 194, 27 | 'lyrics': 197, 28 | 'blog': 199, 29 | 'allmusic': 283, 30 | 'soundcloud': 291, 31 | 'video channel': 303, 32 | 'secondhandsongs': 307, 33 | 'VIAF': 310, 34 | 'wikidata': 352, 35 | 'interview': 707, 36 | 'bandcamp': 718, 37 | 'IMSLP': 754, 38 | 'songkick': 785, 39 | 'setlistfm': 816, 40 | 'last.fm': 840, 41 | 'online data': 841, 42 | 'BookBrainz': 852, 43 | 'bandsintown': 862, 44 | 'patronage': 897, 45 | 'crowdfunding': 902, 46 | 'CD Baby': 919, 47 | 'streaming': 978, 48 | 'CPDL': 981, 49 | 'youtube music': 1080, 50 | 'apple music': 1131, 51 | 'art gallery': 1192, 52 | 'ticketing': 1193, 53 | } as const; 54 | 55 | export type ArtistUrlLinkType = keyof typeof artistUrlTypeIds; 56 | 57 | export const labelUrlTypeIds = { 58 | 'official site': 219, 59 | 'lyrics': 982, 60 | 'online data': 221, 61 | 'blog': 224, 62 | 'history site': 211, 63 | 'catalog site': 212, 64 | 'logo': 213, 65 | 'fanpage': 214, 66 | 'crowdfunding': 903, 67 | 'patronage': 899, 68 | 'ticketing': 1194, 69 | 'social network': 218, 70 | 'myspace': 215, 71 | 'soundcloud': 290, 72 | 'video channel': 304, 73 | 'youtube': 225, 74 | 'get the music': 957, 75 | 'purchase for mail-order': 960, 76 | 'purchase for download': 959, 77 | 'download for free': 958, 78 | 'free streaming': 997, 79 | 'streaming': 1005, 80 | 'apple music': 1130, 81 | 'bandcamp': 719, 82 | 'other databases': 222, 83 | 'BookBrainz': 851, 84 | 'IMDb': 313, 85 | 'VIAF': 311, 86 | 'discogs': 217, 87 | 'last.fm': 838, 88 | 'secondhandsongs': 977, 89 | 'vgmdb': 210, 90 | 'wikidata': 354, 91 | 'wikipedia': 216, 92 | }; 93 | 94 | export type LabelUrlLinkType = keyof typeof labelUrlTypeIds; 95 | 96 | export const recordingUrlTypeIds = { 97 | 'license': 302, 98 | 'production': 256, 99 | 'IMDB samples': 258, 100 | 'get the music': 257, 101 | 'purchase for download': 254, 102 | 'download for free': 255, 103 | 'free streaming': 268, 104 | 'streaming': 979, 105 | 'crowdfunding': 905, 106 | 'other databases': 306, 107 | 'allmusic': 285, 108 | 'secondhandsongs': 976, 109 | }; 110 | 111 | export type RecordingUrlTypeIds = keyof typeof recordingUrlTypeIds; 112 | 113 | export const releaseUrlTypeIds = { 114 | 'production': 72, 115 | 'amazon asin': 77, 116 | 'discography entry': 288, 117 | 'license': 301, 118 | 'get the music': 73, 119 | 'purchase for mail-order': 79, 120 | 'purchase for download': 74, 121 | 'download for free': 75, 122 | 'free streaming': 85, 123 | 'streaming': 980, 124 | 'crowdfunding page': 906, 125 | 'show notes': 729, 126 | 'other databases': 82, 127 | 'discogs': 76, 128 | 'vgmdb': 86, 129 | 'secondhandsongs': 308, 130 | 'allmusic': 755, 131 | 'BookBrainz': 850, 132 | } as const; 133 | 134 | export type ReleaseUrlLinkType = keyof typeof releaseUrlTypeIds; 135 | 136 | export type UrlLinkType = ArtistUrlLinkType | LabelUrlLinkType | RecordingUrlTypeIds | ReleaseUrlLinkType; 137 | 138 | export const urlTypeIds: Partial>>> = { 139 | artist: artistUrlTypeIds, 140 | label: labelUrlTypeIds, 141 | recording: recordingUrlTypeIds, 142 | release: releaseUrlTypeIds, 143 | }; 144 | -------------------------------------------------------------------------------- /providers/Bandcamp/__snapshots__/mod.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`Bandcamp provider > release lookup > label release with fixed price (which is not free despite minimum_price of 0.0) 1`] = ` 4 | { 5 | artists: [ 6 | { 7 | creditedName: "i miss you sis", 8 | name: "i miss you sis", 9 | }, 10 | ], 11 | availableIn: [ 12 | "XW", 13 | ], 14 | credits: "i miss you sis 15 | 16 | suika.love", 17 | externalLinks: [ 18 | { 19 | types: [ 20 | "paid download", 21 | "free streaming", 22 | ], 23 | url: "https://thedarkthursday.bandcamp.com/album/and-it-was-a-burned-into-my-mind-yet-i-faltered-like-a-broken-record", 24 | }, 25 | ], 26 | gtin: undefined, 27 | images: [ 28 | { 29 | comment: undefined, 30 | thumbUrl: "https://f4.bcbits.com/img/a1695876544_9.jpg", 31 | types: [ 32 | "front", 33 | ], 34 | url: "https://f4.bcbits.com/img/a1695876544_0.jpg", 35 | }, 36 | ], 37 | info: { 38 | messages: [], 39 | providers: [ 40 | { 41 | apiUrl: undefined, 42 | id: "thedarkthursday/and-it-was-a-burned-into-my-mind-yet-i-faltered-like-a-broken-record", 43 | internalName: "bandcamp", 44 | lookup: { 45 | method: "id", 46 | value: "thedarkthursday/and-it-was-a-burned-into-my-mind-yet-i-faltered-like-a-broken-record", 47 | }, 48 | name: "Bandcamp", 49 | url: "https://thedarkthursday.bandcamp.com/album/and-it-was-a-burned-into-my-mind-yet-i-faltered-like-a-broken-record", 50 | }, 51 | ], 52 | }, 53 | labels: [ 54 | { 55 | externalIds: [ 56 | { 57 | id: "thedarkthursday", 58 | provider: "bandcamp", 59 | type: "artist", 60 | }, 61 | ], 62 | name: "The Dark Thursday", 63 | }, 64 | ], 65 | media: [ 66 | { 67 | format: "Digital Media", 68 | tracklist: [ 69 | { 70 | artists: undefined, 71 | length: 81250, 72 | number: 1, 73 | recording: { 74 | externalIds: [ 75 | { 76 | id: "thedarkthursday/suomi-lovely", 77 | provider: "bandcamp", 78 | type: "track", 79 | }, 80 | ], 81 | }, 82 | title: "suomi lovely", 83 | }, 84 | { 85 | artists: undefined, 86 | length: 72019.2, 87 | number: 2, 88 | recording: { 89 | externalIds: [ 90 | { 91 | id: "thedarkthursday/bittersweet-desires-to-overdose-on-my-prescriptions", 92 | provider: "bandcamp", 93 | type: "track", 94 | }, 95 | ], 96 | }, 97 | title: "bittersweet desires to overdose on my prescriptions", 98 | }, 99 | { 100 | artists: undefined, 101 | length: 60942.3, 102 | number: 3, 103 | recording: { 104 | externalIds: [ 105 | { 106 | id: "thedarkthursday/set-fire-to-the-space-snow", 107 | provider: "bandcamp", 108 | type: "track", 109 | }, 110 | ], 111 | }, 112 | title: "set fire to the space snow", 113 | }, 114 | ], 115 | }, 116 | ], 117 | packaging: "None", 118 | releaseDate: { 119 | day: 17, 120 | month: 7, 121 | year: 2019, 122 | }, 123 | status: "Official", 124 | title: "and it was a burned into my mind! yet i faltered like a broken record", 125 | types: undefined, 126 | } 127 | `; 128 | -------------------------------------------------------------------------------- /providers/Bandcamp/mod.test.ts: -------------------------------------------------------------------------------- 1 | import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; 2 | import { stubProviderLookups } from '@/providers/test_stubs.ts'; 3 | import { assert } from 'std/assert/assert.ts'; 4 | import { afterAll, describe } from '@std/testing/bdd'; 5 | import { assertSnapshot } from '@std/testing/snapshot'; 6 | 7 | import BandcampProvider from './mod.ts'; 8 | 9 | describe('Bandcamp provider', () => { 10 | const bc = new BandcampProvider(makeProviderOptions()); 11 | const lookupStub = stubProviderLookups(bc); 12 | 13 | describeProvider(bc, { 14 | urls: [{ 15 | description: 'album page', 16 | url: new URL('https://theuglykings.bandcamp.com/album/darkness-is-my-home'), 17 | id: { type: 'album', id: 'theuglykings/darkness-is-my-home' }, 18 | isCanonical: true, 19 | }, { 20 | description: 'album page with tracking parameter', 21 | url: new URL('https://hiroshi-yoshimura.bandcamp.com/album/flora?from=discover_page'), 22 | id: { type: 'album', id: 'hiroshi-yoshimura/flora' }, 23 | }, { 24 | description: 'standalone track page', 25 | url: new URL('https://zeug.bandcamp.com/track/yeltsa-kcir'), 26 | id: { type: 'track', id: 'zeug/yeltsa-kcir' }, 27 | serializedId: 'zeug/track/yeltsa-kcir', 28 | isCanonical: true, 29 | }, { 30 | description: 'artist page/subdomain', 31 | url: new URL('https://taxikebab.bandcamp.com/'), 32 | id: { type: 'artist', id: 'taxikebab' }, 33 | isCanonical: true, 34 | }, { 35 | description: 'artist /music page', 36 | url: new URL('https://theuglykings.bandcamp.com/music'), 37 | id: { type: 'artist', id: 'theuglykings' }, 38 | isCanonical: false, 39 | }, { 40 | description: 'URL without subdomain', 41 | url: new URL('https://bandcamp.com/discover'), 42 | id: undefined, 43 | }], 44 | releaseLookup: [{ 45 | description: 'label release with fixed price (which is not free despite minimum_price of 0.0)', 46 | release: 'thedarkthursday/and-it-was-a-burned-into-my-mind-yet-i-faltered-like-a-broken-record', 47 | assert: async (release, ctx) => { 48 | await assertSnapshot(ctx, release); 49 | const isFree = release.externalLinks.some((link) => link.types?.includes('free download')); 50 | assert(!isFree, 'Release should not be downloadable for free'); 51 | const accountName = 'thedarkthursday'; 52 | assert( 53 | release.labels?.some((label) => label.externalIds?.some(({ id }) => id === accountName)), 54 | 'Bandcamp account should be linked to a label', 55 | ); 56 | assert( 57 | !release.artists?.some((artist) => artist.externalIds?.some(({ id }) => id === accountName)), 58 | 'Bandcamp account should not be linked to an artist', 59 | ); 60 | }, 61 | }], 62 | }); 63 | 64 | afterAll(() => { 65 | lookupStub.restore(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /providers/Beatport/json_types.ts: -------------------------------------------------------------------------------- 1 | export interface BeatportNextData { 2 | props: { 3 | pageProps: { 4 | dehydratedState: { 5 | queries: 6 | | Array | ArrayQueryResult> 7 | | Array>; 8 | }; 9 | release?: Release; 10 | }; 11 | }; 12 | } 13 | 14 | export interface QueryResult { 15 | queryHash: string; 16 | queryKey: Array>; 17 | } 18 | 19 | export interface ScalarQueryResult extends QueryResult { 20 | state: { 21 | data: T; 22 | status: string; 23 | }; 24 | } 25 | 26 | export interface ArrayQueryResult extends QueryResult { 27 | state: { 28 | data: { 29 | next: string | null; 30 | previous: string | null; 31 | count: number; 32 | page: string; 33 | per_page: number; 34 | results: Array; 35 | }; 36 | status: string; 37 | }; 38 | } 39 | 40 | export interface ReleaseSearchResult { 41 | release_id: number; 42 | upc: string; 43 | } 44 | 45 | export interface SearchResult { 46 | releases: { 47 | data: ReleaseSearchResult[]; 48 | }; 49 | } 50 | 51 | export interface Entity { 52 | id: number; 53 | name: string; 54 | } 55 | 56 | export interface EntityWithUrl extends Entity { 57 | url: string; 58 | } 59 | 60 | export interface Artist extends EntityWithUrl { 61 | image: Image; 62 | slug: string; 63 | } 64 | 65 | export interface Image { 66 | id: number; 67 | uri: string; 68 | dynamic_uri: string; 69 | } 70 | 71 | export interface Label extends Entity { 72 | image: Image; 73 | slug: string; 74 | } 75 | 76 | export interface BpmRange { 77 | min: number; 78 | max: number; 79 | } 80 | 81 | export interface Price { 82 | code: string; 83 | symbol: string; 84 | value: number; 85 | display: string; 86 | } 87 | 88 | export interface MinimalRelease extends Entity { 89 | image: Image; 90 | label: Label; 91 | slug: string; 92 | } 93 | 94 | export interface Release extends MinimalRelease { 95 | artists: Artist[]; 96 | bpm_range: BpmRange; 97 | catalog_number: string; 98 | desc: string | null; 99 | enabled: boolean; 100 | encoded_date: string; 101 | exclusive: boolean; 102 | grid: null; 103 | is_available_for_streaming: boolean; 104 | is_hype: boolean; 105 | new_release_date: string; 106 | override_price: boolean; 107 | pre_order: boolean; 108 | pre_order_date: string | null; 109 | price: Price; 110 | price_override_firm: boolean; 111 | publish_date: string; 112 | remixers: Artist[]; 113 | tracks: string[]; 114 | track_count: number; 115 | type: Entity; 116 | upc: string | null; 117 | updated: string; 118 | } 119 | 120 | export interface Genre extends EntityWithUrl { 121 | slug: string; 122 | } 123 | 124 | export interface Key extends EntityWithUrl { 125 | camelot_number: number; 126 | camelot_letter: string; 127 | chord_type: EntityWithUrl; 128 | is_sharp: boolean; 129 | is_flat: boolean; 130 | letter: string; 131 | } 132 | 133 | export interface Track extends EntityWithUrl { 134 | artists: Artist[]; 135 | publish_status: string; 136 | available_worldwide: boolean; 137 | bpm: number; 138 | catalog_number: string; 139 | current_status: EntityWithUrl; 140 | encoded_date: string; 141 | exclusive: boolean; 142 | // @todo find tracks where this is not empty 143 | free_downloads: []; 144 | free_download_start_date: null; 145 | free_download_end_date: null; 146 | genre: Genre; 147 | is_available_for_streaming: boolean; 148 | is_hype: boolean; 149 | isrc: string; 150 | key: Key; 151 | label_track_identifier: string; 152 | length: string; 153 | length_ms: number; 154 | mix_name: string; 155 | new_release_date: string; 156 | pre_order: boolean; 157 | price: Price; 158 | publish_date: string; 159 | release: MinimalRelease; 160 | remixers: Artist[]; 161 | sale_type: EntityWithUrl; 162 | sample_url: string; 163 | sample_start_ms: number; 164 | sample_end_ms: number; 165 | slug: string; 166 | // @todo find examples where this is set 167 | sub_genre: null; 168 | } 169 | 170 | export interface BeatportRelease extends Release { 171 | track_objects: Track[]; 172 | } 173 | -------------------------------------------------------------------------------- /providers/Beatport/mod.test.ts: -------------------------------------------------------------------------------- 1 | import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; 2 | import { describe } from '@std/testing/bdd'; 3 | 4 | import BeatportProvider from './mod.ts'; 5 | 6 | describe('Beatport provider', () => { 7 | const beatport = new BeatportProvider(makeProviderOptions()); 8 | 9 | describeProvider(beatport, { 10 | urls: [{ 11 | description: 'release URL with slug', 12 | url: new URL('https://www.beatport.com/release/black-mill-tapes-10th-anniversary-box/3176998'), 13 | id: { type: 'release', id: '3176998', slug: 'black-mill-tapes-10th-anniversary-box' }, 14 | isCanonical: true, 15 | }, { 16 | description: 'artist URL with slug', 17 | url: new URL('https://www.beatport.com/artist/deadmau5/26182'), 18 | id: { type: 'artist', id: '26182', slug: 'deadmau5' }, 19 | isCanonical: true, 20 | }, { 21 | description: 'label URL with slug', 22 | url: new URL('https://www.beatport.com/label/physical-techno-recordings/41056'), 23 | id: { type: 'label', id: '41056', slug: 'physical-techno-recordings' }, 24 | isCanonical: true, 25 | }, { 26 | description: 'track URL with slug', 27 | url: new URL('https://www.beatport.com/track/tokyo-night/19209410'), 28 | id: { type: 'track', id: '19209410', slug: 'tokyo-night' }, 29 | isCanonical: true, 30 | }], 31 | releaseLookup: [], 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /providers/Deezer/api_types.ts: -------------------------------------------------------------------------------- 1 | export type MinimalArtist = { 2 | /** The artist's Deezer id */ 3 | id: number; 4 | name: string; 5 | /** API Link to the top of this artist */ 6 | tracklist: string; 7 | type: string; 8 | }; 9 | 10 | export type ReleaseArtist = MinimalArtist & Pictures; 11 | 12 | export type TrackArtist = ReleaseArtist & { 13 | /** The url of the artist on Deezer */ 14 | link: string; 15 | /** The share link of the artist on Deezer */ 16 | share: string; 17 | /** true if the artist has a smartradio */ 18 | radio: boolean; 19 | }; 20 | 21 | export type Artist = TrackArtist & { 22 | /** The number of artist's albums */ 23 | nb_album: number; 24 | /** The number of artist's fans */ 25 | nb_fan: number; 26 | }; 27 | 28 | export type Contributor = TrackArtist & { 29 | role: string; 30 | }; 31 | 32 | export type ReleaseGenre = { 33 | id: number; 34 | name: string; 35 | picture: string; 36 | type: string; 37 | }; 38 | 39 | export type Genre = ReleaseGenre & Pictures; 40 | 41 | export type Pictures = { 42 | /** The url of the picture. Add 'size' parameter to the url to change size. Can be 'small', 'medium', 'big', 'xl' */ 43 | picture: string; 44 | picture_small: string; 45 | picture_medium: string; 46 | picture_big: string; 47 | picture_xl: string; 48 | }; 49 | 50 | export type MinimalRelease = { 51 | /** The Deezer album id */ 52 | id: number; 53 | title: string; 54 | /** The url of the album on Deezer */ 55 | link: string; 56 | /** The url of the album's cover. Add 'size' parameter to the url to change size. Can be 'small', 'medium', 'big', 'xl' */ 57 | cover: string | ''; 58 | cover_small: string | null; 59 | cover_medium: string | null; 60 | cover_big: string | null; 61 | cover_xl: string | null; 62 | md5_image: string; 63 | /** The album's release date */ 64 | release_date: string; 65 | /** API Link to the tracklist of this album */ 66 | tracklist: string; 67 | type: string; 68 | }; 69 | 70 | export type Release = MinimalRelease & { 71 | upc: string; 72 | /** The share link of the album on Deezer */ 73 | share: string; 74 | /** The album's first genre id (You should use the genre list instead). NB : -1 for not found */ 75 | genre_id: number; 76 | genres: { data: ReleaseGenre[] }; 77 | /** The album's label name */ 78 | label: string; 79 | nb_tracks: number; 80 | /** The album's duration (seconds) */ 81 | duration: number; 82 | /** The number of album's Fans */ 83 | fans: number; 84 | rating: number; // missing from https://developers.deezer.com/api/album 85 | /** The record type of the album (EP / ALBUM / COMPILE etc..) */ 86 | record_type: string; 87 | available: boolean; 88 | /** Whether the album contains explicit lyrics */ 89 | explicit_lyrics: boolean; 90 | /** The explicit content lyrics values (0:Not Explicit; 1:Explicit; 2:Unknown; 3:Edited; 4:Partially Explicit (Album "lyrics" only); 5:Partially Unknown (Album "lyrics" only); 6:No Advice Available; 7:Partially No Advice Available (Album "lyrics" only)) */ 91 | explicit_content_lyrics: number; 92 | /** The explicit cover values (see `explicit_content_lyrics`) */ 93 | explicit_content_cover: number; 94 | /** Return a list of contributors on the album */ 95 | contributors: Contributor[]; 96 | artist: ReleaseArtist; 97 | tracks: { data: ReleaseTrack[] }; 98 | }; 99 | 100 | export type ReleaseTrack = { 101 | id: number; 102 | readable: boolean; 103 | title: string; 104 | title_short: string; 105 | title_version: string; 106 | link: string; 107 | duration: number; 108 | rank: string; 109 | explicit_lyrics: boolean; 110 | explicit_content_lyrics: number; 111 | explicit_content_cover: number; 112 | preview: string; 113 | md5_image: string; 114 | artist: MinimalArtist; 115 | type: string; 116 | }; 117 | 118 | export type TracklistItem = ReleaseTrack & { 119 | isrc: string; 120 | track_position: number; 121 | disk_number: number; 122 | }; 123 | 124 | export type Track = TracklistItem & { 125 | share: string; 126 | release_date: string; 127 | bpm: number; 128 | gain: number; 129 | available_countries: string[]; 130 | contributors: Contributor[]; 131 | artist: TrackArtist; 132 | album: MinimalRelease; 133 | type: string; 134 | }; 135 | 136 | export type ApiError = { 137 | code: number; 138 | message: string; 139 | type: ErrorType; 140 | }; 141 | 142 | export type ErrorType = 143 | | 'Exception' 144 | | 'OAuthException' 145 | | 'ParameterException' 146 | | 'MissingParameterException' 147 | | 'InvalidQueryException' 148 | | 'DataException' 149 | | 'IndividualAccountChangedNotAllowedException'; 150 | 151 | export type Result = { 152 | data: T[]; 153 | total: number; 154 | prev?: string; 155 | next?: string; 156 | error?: ApiError; 157 | }; 158 | -------------------------------------------------------------------------------- /providers/Deezer/mod.test.ts: -------------------------------------------------------------------------------- 1 | import type { ReleaseOptions } from '@/harmonizer/types.ts'; 2 | import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; 3 | import { stubProviderLookups } from '@/providers/test_stubs.ts'; 4 | import { assert } from 'std/assert/assert.ts'; 5 | import { afterAll, describe } from '@std/testing/bdd'; 6 | import { assertSnapshot } from '@std/testing/snapshot'; 7 | 8 | import DeezerProvider from './mod.ts'; 9 | 10 | describe('Deezer provider', () => { 11 | const deezer = new DeezerProvider(makeProviderOptions()); 12 | const lookupStub = stubProviderLookups(deezer); 13 | 14 | // Standard options which have an effect for Deezer. 15 | const releaseOptions: ReleaseOptions = { 16 | withSeparateMedia: true, 17 | withAllTrackArtists: true, 18 | withISRC: true, 19 | }; 20 | 21 | describeProvider(deezer, { 22 | urls: [{ 23 | description: 'release page', 24 | url: new URL('https://www.deezer.com/album/11591214'), 25 | id: { type: 'album', id: '11591214' }, 26 | isCanonical: true, 27 | }, { 28 | description: 'localized release page', 29 | url: new URL('https://www.deezer.com/fr/album/629506181'), 30 | id: { type: 'album', id: '629506181' }, 31 | }, { 32 | description: 'localized release page without www subdomain', 33 | url: new URL('https://deezer.com/en/album/521038732'), 34 | id: { type: 'album', id: '521038732' }, 35 | }, { 36 | description: 'artist page', 37 | url: new URL('https://www.deezer.com/artist/8686'), 38 | id: { type: 'artist', id: '8686' }, 39 | isCanonical: true, 40 | }, { 41 | description: 'track page', 42 | url: new URL('https://www.deezer.com/track/3245455'), 43 | id: { type: 'track', id: '3245455' }, 44 | isCanonical: true, 45 | }, { 46 | description: 'playlist page', 47 | url: new URL('https://www.deezer.com/en/playlist/1976454162'), 48 | id: undefined, 49 | }], 50 | releaseLookup: [{ 51 | description: 'single by two artists', 52 | release: new URL('https://www.deezer.com/en/album/629506181'), 53 | options: releaseOptions, 54 | assert: async (release, ctx) => { 55 | await assertSnapshot(ctx, release); 56 | const allTracks = release.media.flatMap((medium) => medium.tracklist); 57 | assert(allTracks[0].artists?.length === 2, 'Main track should have two artists'); 58 | assert(allTracks.every((track) => track.isrc), 'All tracks should have an ISRC'); 59 | }, 60 | }, { 61 | description: 'single by two artists (without additional lookup options)', 62 | release: '629506181', // same single as in the previous test 63 | assert: (release) => { 64 | const allTracks = release.media.flatMap((medium) => medium.tracklist); 65 | assert(allTracks.every((track) => track.artists?.length === 1), 'Tracks should not have multiple artists'); 66 | assert(allTracks.every((track) => !track.isrc), 'Tracks should not have an ISRC'); 67 | }, 68 | }], 69 | }); 70 | 71 | afterAll(() => { 72 | lookupStub.restore(); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /providers/Deezer/regions.ts: -------------------------------------------------------------------------------- 1 | // Extracted from https://developers.deezer.com/guidelines/countries 2 | 3 | export const availableRegions = [ 4 | 'AE', 5 | 'AF', 6 | 'AG', 7 | 'AI', 8 | 'AL', 9 | 'AM', 10 | 'AO', 11 | 'AR', 12 | 'AT', 13 | 'AU', 14 | 'AZ', 15 | 'BA', 16 | 'BB', 17 | 'BD', 18 | 'BE', 19 | 'BF', 20 | 'BG', 21 | 'BH', 22 | 'BI', 23 | 'BJ', 24 | 'BN', 25 | 'BO', 26 | 'BR', 27 | 'BT', 28 | 'BW', 29 | 'CA', 30 | 'CC', 31 | 'CD', 32 | 'CF', 33 | 'CG', 34 | 'CH', 35 | 'CI', 36 | 'CK', 37 | 'CL', 38 | 'CM', 39 | 'CO', 40 | 'CR', 41 | 'CV', 42 | 'CX', 43 | 'CY', 44 | 'CZ', 45 | 'DE', 46 | 'DJ', 47 | 'DK', 48 | 'DM', 49 | 'DO', 50 | 'DZ', 51 | 'EC', 52 | 'EE', 53 | 'EG', 54 | 'ER', 55 | 'ES', 56 | 'ET', 57 | 'FI', 58 | 'FJ', 59 | 'FK', 60 | 'FM', 61 | 'FR', 62 | 'GA', 63 | 'GB', 64 | 'GD', 65 | 'GE', 66 | 'GH', 67 | 'GM', 68 | 'GN', 69 | 'GQ', 70 | 'GR', 71 | 'GT', 72 | 'GW', 73 | 'HN', 74 | 'HR', 75 | 'HU', 76 | 'ID', 77 | 'IE', 78 | 'IL', 79 | 'IO', 80 | 'IQ', 81 | 'IS', 82 | 'IT', 83 | 'JM', 84 | 'JO', 85 | 'JP', 86 | 'KE', 87 | 'KG', 88 | 'KH', 89 | 'KI', 90 | 'KM', 91 | 'KN', 92 | 'KW', 93 | 'KY', 94 | 'KZ', 95 | 'LA', 96 | 'LB', 97 | 'LC', 98 | 'LK', 99 | 'LR', 100 | 'LS', 101 | 'LT', 102 | 'LU', 103 | 'LV', 104 | 'LY', 105 | 'MA', 106 | 'MD', 107 | 'ME', 108 | 'MG', 109 | 'MH', 110 | 'MK', 111 | 'ML', 112 | 'MN', 113 | 'MR', 114 | 'MS', 115 | 'MT', 116 | 'MU', 117 | 'MV', 118 | 'MW', 119 | 'MX', 120 | 'MY', 121 | 'MZ', 122 | 'NA', 123 | 'NE', 124 | 'NF', 125 | 'NG', 126 | 'NI', 127 | 'NL', 128 | 'NO', 129 | 'NP', 130 | 'NR', 131 | 'NU', 132 | 'NZ', 133 | 'OM', 134 | 'PA', 135 | 'PE', 136 | 'PG', 137 | 'PH', 138 | 'PK', 139 | 'PL', 140 | 'PN', 141 | 'PT', 142 | 'PW', 143 | 'PY', 144 | 'QA', 145 | 'RO', 146 | 'RS', 147 | 'RW', 148 | 'SA', 149 | 'SB', 150 | 'SC', 151 | 'SE', 152 | 'SG', 153 | 'SI', 154 | 'SJ', 155 | 'SK', 156 | 'SL', 157 | 'SN', 158 | 'SO', 159 | 'ST', 160 | 'SV', 161 | 'SZ', 162 | 'TC', 163 | 'TD', 164 | 'TG', 165 | 'TH', 166 | 'TJ', 167 | 'TK', 168 | 'TL', 169 | 'TM', 170 | 'TN', 171 | 'TO', 172 | 'TR', 173 | 'TV', 174 | 'TZ', 175 | 'UA', 176 | 'UG', 177 | 'US', 178 | 'UY', 179 | 'UZ', 180 | 'VC', 181 | 'VE', 182 | 'VG', 183 | 'VN', 184 | 'VU', 185 | 'WS', 186 | 'YE', 187 | 'ZA', 188 | 'ZM', 189 | 'ZW', 190 | ]; 191 | -------------------------------------------------------------------------------- /providers/Spotify/api_types.ts: -------------------------------------------------------------------------------- 1 | export type SimplifiedAlbum = { 2 | id: string; 3 | type: 'album'; 4 | href: string; 5 | name: string; 6 | uri: string; 7 | artists: SimplifiedArtist[]; 8 | album_type: AlbumType; 9 | total_tracks: number; 10 | release_date: string; 11 | release_date_precision: ReleaseDatePrecision; 12 | external_urls: { spotify: string }; 13 | images: Image[]; 14 | available_markets: string[]; 15 | restrictions: { reason: string }; 16 | }; 17 | 18 | export type Album = SimplifiedAlbum & { 19 | tracks: ResultList; 20 | copyrights: Copyright[]; 21 | external_ids: ExternalIds; 22 | genres: string[]; 23 | label: string; 24 | /** The popularity of the album. The value will be between 0 and 100, with 100 being the most popular. */ 25 | popularity: number; 26 | }; 27 | 28 | export type SimplifiedArtist = { 29 | id: string; 30 | type: 'artist'; 31 | href: string; 32 | name: string; 33 | uri: string; 34 | }; 35 | 36 | export type Artist = SimplifiedArtist & { 37 | followers: { href: string; total: number }; 38 | }; 39 | 40 | export type LinkedTrack = { 41 | id: string; 42 | type: 'track'; 43 | href: string; 44 | uri: string; 45 | external_urls: { spotify: string }; 46 | }; 47 | 48 | export type SimplifiedTrack = LinkedTrack & { 49 | name: string; 50 | artists: SimplifiedArtist[]; 51 | track_number: number; 52 | disc_number: number; 53 | duration_ms: number; 54 | explicit: boolean; 55 | is_playable: boolean | undefined; 56 | is_local: boolean; 57 | preview_url: string; 58 | linked_from: LinkedTrack | undefined; 59 | available_markets: string[]; 60 | restrictions: { reason: string }; 61 | }; 62 | 63 | export type Track = SimplifiedTrack & { 64 | album: Album; 65 | artists: Artist[]; 66 | external_ids: ExternalIds; 67 | popularity: number; 68 | }; 69 | 70 | export type Image = { 71 | url: string; 72 | width: number; 73 | height: number; 74 | }; 75 | 76 | export type Copyright = { 77 | text: string; 78 | type: CopyrightType; 79 | }; 80 | 81 | export type ExternalIds = { 82 | isrc: string; 83 | ean: string; 84 | upc: string; 85 | }; 86 | 87 | export type ReleaseDatePrecision = 'year' | 'month' | 'day'; 88 | 89 | export type AlbumType = 'album' | 'single' | 'compilation'; 90 | 91 | export type CopyrightType = 'C' | 'P'; 92 | 93 | export type BaseResultList = { 94 | href: string; 95 | limit: number; 96 | offset: number; 97 | total: number; 98 | next: string; 99 | previous: string; 100 | }; 101 | 102 | export type TrackList = BaseResultList & { 103 | tracks: Track[]; 104 | }; 105 | 106 | export type ResultList = BaseResultList & { 107 | items: T[]; 108 | }; 109 | 110 | export type SearchResult = { 111 | albums: ResultList; 112 | tracks: ResultList; 113 | artists: ResultList; 114 | // Unsupported / not needed: 115 | // Playlists: ResultList; 116 | // Shows: ResultList; 117 | // Episodes: ResultList; 118 | // Audiobooks: ResultList; 119 | }; 120 | 121 | export type ApiError = { 122 | error: { 123 | status: number; 124 | message: string; 125 | }; 126 | }; 127 | -------------------------------------------------------------------------------- /providers/Spotify/mod.test.ts: -------------------------------------------------------------------------------- 1 | // Automatically load .env environment variable file (before anything else). 2 | import 'std/dotenv/load.ts'; 3 | 4 | import type { ReleaseOptions } from '@/harmonizer/types.ts'; 5 | import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; 6 | import { stubProviderLookups, stubTokenRetrieval } from '@/providers/test_stubs.ts'; 7 | import { downloadMode } from '@/utils/fetch_stub.ts'; 8 | import { assert } from 'std/assert/assert.ts'; 9 | import { afterAll, describe } from '@std/testing/bdd'; 10 | import type { Stub } from '@std/testing/mock'; 11 | import { assertSnapshot } from '@std/testing/snapshot'; 12 | 13 | import SpotifyProvider from './mod.ts'; 14 | 15 | describe('Spotify provider', () => { 16 | const spotify = new SpotifyProvider(makeProviderOptions()); 17 | const stubs: Stub[] = [stubProviderLookups(spotify)]; 18 | 19 | if (!downloadMode) { 20 | stubs.push(stubTokenRetrieval(spotify)); 21 | } 22 | 23 | // Standard options which have an effect for Spotify. 24 | const releaseOptions: ReleaseOptions = { 25 | withISRC: true, 26 | }; 27 | 28 | describeProvider(spotify, { 29 | urls: [{ 30 | description: 'release page', 31 | url: new URL('https://open.spotify.com/album/2ZYLme9VXn1Eo8JDtfN7Y9'), 32 | id: { type: 'album', id: '2ZYLme9VXn1Eo8JDtfN7Y9' }, 33 | isCanonical: true, 34 | }, { 35 | description: 'localized release page', 36 | url: new URL('https://open.spotify.com/intl-de/album/0m8KmmObidPRHOP5knBkam'), 37 | id: { type: 'album', id: '0m8KmmObidPRHOP5knBkam' }, 38 | }, { 39 | description: 'artist page', 40 | url: new URL('https://open.spotify.com/artist/2lD1D6eEh7xQdBtnl2Ik7Y'), 41 | id: { type: 'artist', id: '2lD1D6eEh7xQdBtnl2Ik7Y' }, 42 | isCanonical: true, 43 | }, { 44 | description: 'artist page with tracking parameter', 45 | url: new URL('https://open.spotify.com/artist/163tK9Wjr9P9DmM0AVK7lm?si=dcd20f1a6b8b49a3'), 46 | id: { type: 'artist', id: '163tK9Wjr9P9DmM0AVK7lm' }, 47 | }, { 48 | description: 'track page', 49 | url: new URL('https://open.spotify.com/track/1EDPVGbyPKJPeGqATwXZvN'), 50 | id: { type: 'track', id: '1EDPVGbyPKJPeGqATwXZvN' }, 51 | isCanonical: true, 52 | }], 53 | releaseLookup: [{ 54 | description: 'single by two artists', 55 | release: new URL('https://open.spotify.com/album/10FLjwfpbxLmW8c25Xyc2N'), 56 | options: releaseOptions, 57 | assert: async (release, ctx) => { 58 | await assertSnapshot(ctx, release); 59 | const allTracks = release.media.flatMap((medium) => medium.tracklist); 60 | assert(allTracks[0].artists?.length === 2, 'Main track should have two artists'); 61 | assert(allTracks.every((track) => track.isrc), 'All tracks should have an ISRC'); 62 | }, 63 | }, { 64 | description: 'single by two artists (without additional lookup options)', 65 | release: '10FLjwfpbxLmW8c25Xyc2N', // same single as in the previous test 66 | assert: (release) => { 67 | const allTracks = release.media.flatMap((medium) => medium.tracklist); 68 | assert(allTracks[0].artists?.length === 2, 'Main track should have two artists'); 69 | assert(allTracks.every((track) => !track.isrc), 'Tracks should not have an ISRC'); 70 | }, 71 | }], 72 | }); 73 | 74 | afterAll(() => { 75 | stubs.forEach((stub) => stub.restore()); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /providers/Spotify/regions.ts: -------------------------------------------------------------------------------- 1 | // Fetched from API endpoint https://developer.spotify.com/documentation/web-api/reference/get-available-markets 2 | 3 | export const availableRegions = [ 4 | 'AD', 5 | 'AE', 6 | 'AG', 7 | 'AL', 8 | 'AM', 9 | 'AO', 10 | 'AR', 11 | 'AT', 12 | 'AU', 13 | 'AZ', 14 | 'BA', 15 | 'BB', 16 | 'BD', 17 | 'BE', 18 | 'BF', 19 | 'BG', 20 | 'BH', 21 | 'BI', 22 | 'BJ', 23 | 'BN', 24 | 'BO', 25 | 'BR', 26 | 'BS', 27 | 'BT', 28 | 'BW', 29 | 'BY', 30 | 'BZ', 31 | 'CA', 32 | 'CD', 33 | 'CG', 34 | 'CH', 35 | 'CI', 36 | 'CL', 37 | 'CM', 38 | 'CO', 39 | 'CR', 40 | 'CV', 41 | 'CW', 42 | 'CY', 43 | 'CZ', 44 | 'DE', 45 | 'DJ', 46 | 'DK', 47 | 'DM', 48 | 'DO', 49 | 'DZ', 50 | 'EC', 51 | 'EE', 52 | 'EG', 53 | 'ES', 54 | 'ET', 55 | 'FI', 56 | 'FJ', 57 | 'FM', 58 | 'FR', 59 | 'GA', 60 | 'GB', 61 | 'GD', 62 | 'GE', 63 | 'GH', 64 | 'GM', 65 | 'GN', 66 | 'GQ', 67 | 'GR', 68 | 'GT', 69 | 'GW', 70 | 'GY', 71 | 'HK', 72 | 'HN', 73 | 'HR', 74 | 'HT', 75 | 'HU', 76 | 'ID', 77 | 'IE', 78 | 'IL', 79 | 'IN', 80 | 'IQ', 81 | 'IS', 82 | 'IT', 83 | 'JM', 84 | 'JO', 85 | 'JP', 86 | 'KE', 87 | 'KG', 88 | 'KH', 89 | 'KI', 90 | 'KM', 91 | 'KN', 92 | 'KR', 93 | 'KW', 94 | 'KZ', 95 | 'LA', 96 | 'LB', 97 | 'LC', 98 | 'LI', 99 | 'LK', 100 | 'LR', 101 | 'LS', 102 | 'LT', 103 | 'LU', 104 | 'LV', 105 | 'LY', 106 | 'MA', 107 | 'MC', 108 | 'MD', 109 | 'ME', 110 | 'MG', 111 | 'MH', 112 | 'MK', 113 | 'ML', 114 | 'MN', 115 | 'MO', 116 | 'MR', 117 | 'MT', 118 | 'MU', 119 | 'MV', 120 | 'MW', 121 | 'MX', 122 | 'MY', 123 | 'MZ', 124 | 'NA', 125 | 'NE', 126 | 'NG', 127 | 'NI', 128 | 'NL', 129 | 'NO', 130 | 'NP', 131 | 'NR', 132 | 'NZ', 133 | 'OM', 134 | 'PA', 135 | 'PE', 136 | 'PG', 137 | 'PH', 138 | 'PK', 139 | 'PL', 140 | 'PR', 141 | 'PS', 142 | 'PT', 143 | 'PW', 144 | 'PY', 145 | 'QA', 146 | 'RO', 147 | 'RS', 148 | 'RW', 149 | 'SA', 150 | 'SB', 151 | 'SC', 152 | 'SE', 153 | 'SG', 154 | 'SI', 155 | 'SK', 156 | 'SL', 157 | 'SM', 158 | 'SN', 159 | 'SR', 160 | 'ST', 161 | 'SV', 162 | 'SZ', 163 | 'TD', 164 | 'TG', 165 | 'TH', 166 | 'TJ', 167 | 'TL', 168 | 'TN', 169 | 'TO', 170 | 'TR', 171 | 'TT', 172 | 'TV', 173 | 'TW', 174 | 'TZ', 175 | 'UA', 176 | 'UG', 177 | 'US', 178 | 'UY', 179 | 'UZ', 180 | 'VC', 181 | 'VE', 182 | 'VN', 183 | 'VU', 184 | 'WS', 185 | 'XK', 186 | 'ZA', 187 | 'ZM', 188 | 'ZW', 189 | ]; 190 | -------------------------------------------------------------------------------- /providers/Tidal/mod.test.ts: -------------------------------------------------------------------------------- 1 | // Automatically load .env environment variable file (before anything else). 2 | import 'std/dotenv/load.ts'; 3 | 4 | import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; 5 | import { stubProviderLookups, stubTokenRetrieval } from '@/providers/test_stubs.ts'; 6 | import { downloadMode } from '@/utils/fetch_stub.ts'; 7 | import { assert } from 'std/assert/assert.ts'; 8 | import { afterAll, describe } from '@std/testing/bdd'; 9 | import type { Stub } from '@std/testing/mock'; 10 | import { assertSnapshot } from '@std/testing/snapshot'; 11 | 12 | import TidalProvider from './mod.ts'; 13 | 14 | describe('Tidal provider', () => { 15 | const tidal = new TidalProvider(makeProviderOptions()); 16 | const stubs: Stub[] = [stubProviderLookups(tidal)]; 17 | 18 | if (!downloadMode) { 19 | stubs.push(stubTokenRetrieval(tidal)); 20 | } 21 | 22 | describeProvider(tidal, { 23 | urls: [{ 24 | description: 'release page', 25 | url: new URL('https://tidal.com/album/357676034'), 26 | id: { type: 'album', id: '357676034' }, 27 | isCanonical: true, 28 | }, { 29 | description: 'release /browse page', 30 | url: new URL('https://tidal.com/browse/album/130201923'), 31 | id: { type: 'album', id: '130201923' }, 32 | }, { 33 | description: 'listen.tidal.com release page', 34 | url: new URL('https://listen.tidal.com/album/11343637'), 35 | id: { type: 'album', id: '11343637' }, 36 | }, { 37 | description: 'release page with /track suffix', 38 | url: new URL('https://listen.tidal.com/album/301366648/track/301366649'), 39 | id: { type: 'album', id: '301366648' }, 40 | }, { 41 | description: 'artist page', 42 | url: new URL('https://tidal.com/artist/80'), 43 | id: { type: 'artist', id: '80' }, 44 | isCanonical: true, 45 | }, { 46 | description: 'artist /browse page', 47 | url: new URL('https://tidal.com/browse/artist/3557299'), 48 | id: { type: 'artist', id: '3557299' }, 49 | }, { 50 | description: 'listen.tidal.com artist page', 51 | url: new URL('https://listen.tidal.com/artist/116'), 52 | id: { type: 'artist', id: '116' }, 53 | }, { 54 | description: 'track page', 55 | url: new URL('https://tidal.com/track/196091131'), 56 | id: { type: 'track', id: '196091131' }, 57 | isCanonical: true, 58 | }, { 59 | description: 'track /browse page', 60 | url: new URL('https://tidal.com/browse/track/11343638'), 61 | id: { type: 'track', id: '11343638' }, 62 | }], 63 | releaseLookup: [{ 64 | description: 'live album with video tracks and featured artist (v1 API)', 65 | release: new URL('https://tidal.com/album/130201923'), 66 | options: { 67 | // Use data from an old snapshot which was made when the v1 API was still live. 68 | snapshotMaxTimestamp: 1717684821, 69 | regions: new Set(['GB']), 70 | }, 71 | assert: async (release, ctx) => { 72 | await assertSnapshot(ctx, release); 73 | const allTracks = release.media.flatMap((medium) => medium.tracklist); 74 | assert(allTracks[5].artists?.length === 2, 'Track 6 should have two artists'); 75 | assert(allTracks[8].type === 'video', 'Track 9 should be a video'); 76 | assert(allTracks.every((track) => track.isrc), 'All tracks should have an ISRC'); 77 | }, 78 | }], 79 | }); 80 | 81 | afterAll(() => { 82 | stubs.forEach((stub) => stub.restore()); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /providers/Tidal/regions.ts: -------------------------------------------------------------------------------- 1 | // Availability of Tidal: https://support.tidal.com/hc/en-us/articles/202453191-Availability-by-Country 2 | 3 | export const availableRegions = [ 4 | 'AD', // Andorra 5 | 'AE', // United Arab Emirates 6 | 'AL', // Albania 7 | 'AR', // Argentina 8 | 'AT', // Austria 9 | 'AU', // Australia 10 | 'BA', // Bosnia and Herzegovina 11 | 'BE', // Belgium 12 | 'BG', // Bulgaria 13 | 'BR', // Brazil 14 | 'CA', // Canada 15 | 'CH', // Switzerland 16 | 'CL', // Chile 17 | 'CO', // Colombia 18 | 'CY', // Cyprus 19 | 'CZ', // Czech Republic 20 | 'DE', // Germany 21 | 'DK', // Denmark 22 | 'DM', // Dominican Republic 23 | 'EE', // Estonia 24 | 'ES', // Spain 25 | 'FI', // Finland 26 | 'FR', // France 27 | 'GB', // United Kingdom 28 | 'GR', // Greece 29 | 'HK', // Hong Kong 30 | 'HR', // Croatia 31 | 'HU', // Hungary 32 | 'IL', // Israel 33 | 'IR', // Ireland 34 | 'IS', // Iceland 35 | 'IT', // Italy 36 | 'JM', // Jamaica 37 | 'LI', // Liechtenstein 38 | 'LT', // Lithuania 39 | 'LU', // Luxembourg 40 | 'LV', // Latvia 41 | 'MC', // Monaco 42 | 'ME', // Montenegro 43 | 'MK', // North Macedonia 44 | 'MT', // Malta 45 | 'MX', // Mexico 46 | 'MY', // Malaysia 47 | 'NG', // Nigeria 48 | 'NL', // Netherlands 49 | 'NO', // Norway 50 | 'NZ', // New Zealand 51 | 'PE', // Peru 52 | 'PL', // Poland 53 | 'PR', // Puerto Rico 54 | 'PT', // Portugal 55 | 'RO', // Romania 56 | 'RS', // Serbia 57 | 'SE', // Sweden' 58 | 'SG', // Singapore 59 | 'SI', // Slovenia 60 | 'SK', // Slovakia 61 | 'TH', // Thailand 62 | 'UG', // Uganda 63 | 'US', // United States of America 64 | 'ZA', // South Africa 65 | ]; 66 | -------------------------------------------------------------------------------- /providers/Tidal/v1/api_types.ts: -------------------------------------------------------------------------------- 1 | export type SimpleArtist = { 2 | /** The Tidal ID */ 3 | id: string; 4 | name: string; 5 | picture: Image[]; 6 | main: boolean; 7 | }; 8 | 9 | export type Artist = SimpleArtist & { 10 | tidalUrl: string; 11 | popularity: number; 12 | }; 13 | 14 | export type SimpleAlbum = { 15 | /** The Tidal ID */ 16 | id: string; 17 | title: string; 18 | imageCover: Image[]; 19 | videoCover: Image[]; 20 | }; 21 | 22 | export type Album = SimpleAlbum & { 23 | barcodeId: string; 24 | artists: SimpleArtist[]; 25 | /** Full release duration in seconds */ 26 | duration: number; 27 | /** Release date in YYYY-MM-DD format */ 28 | releaseDate: string; 29 | numberOfVolumes: number; 30 | numberOfTracks: number; 31 | numberOfVideos: number; 32 | type: 'ALBUM' | 'EP' | 'SINGLE'; 33 | copyright?: string; 34 | mediaMetadata: MediaMetadata; 35 | properties: Properties; 36 | tidalUrl: string; 37 | providerInfo: ProviderInfo; 38 | popularity: number; 39 | }; 40 | 41 | export type CommonAlbumItem = { 42 | artifactType: 'track' | 'video'; 43 | /** The Tidal ID */ 44 | id: string; 45 | title: string; 46 | artists: SimpleArtist[]; 47 | /** Track duration in seconds */ 48 | duration: number; 49 | /** Version of the album's item; complements title */ 50 | version: string; 51 | album: SimpleAlbum; 52 | trackNumber: number; 53 | volumeNumber: number; 54 | isrc: string; 55 | copyright?: string; 56 | mediaMetadata: MediaMetadata; 57 | tidalUrl: string; 58 | providerInfo: ProviderInfo; 59 | popularity: number; 60 | }; 61 | 62 | export type Track = CommonAlbumItem & { 63 | properties: Properties; 64 | }; 65 | 66 | export type Video = CommonAlbumItem & { 67 | properties: VideoProperties; 68 | image: Image; 69 | releaseDate: string; 70 | }; 71 | 72 | export type AlbumItem = Track | Video; 73 | 74 | export type Image = { 75 | url: string; 76 | width: number; 77 | height: number; 78 | }; 79 | 80 | export type Resource = { 81 | id: string; 82 | status: number; 83 | message: string; 84 | resource: T; 85 | }; 86 | 87 | export type MediaMetadata = { 88 | tags: string[]; 89 | }; 90 | 91 | export type Properties = { 92 | /** Can be "explicit", other? */ 93 | content: string; 94 | }; 95 | 96 | export type VideoProperties = Properties & { 97 | /** Example: live-stream */ 98 | 'video-type': string; 99 | }; 100 | 101 | export type ProviderInfo = { 102 | providerId: string; 103 | providerName: string; 104 | }; 105 | 106 | export type Error = { 107 | category: string; 108 | code: string; 109 | detail: string; 110 | field?: string; 111 | }; 112 | 113 | export type ApiError = { 114 | errors: Error[]; 115 | }; 116 | 117 | export type ResultMetadata = { 118 | total: number; 119 | requested: number; 120 | success: number; 121 | failure: number; 122 | }; 123 | 124 | export type Result = { 125 | data: Resource[]; 126 | metadata: ResultMetadata; 127 | }; 128 | -------------------------------------------------------------------------------- /providers/features.ts: -------------------------------------------------------------------------------- 1 | const providerFeatures = [ 2 | 'cover size', 3 | 'duration precision', 4 | 'GTIN lookup', 5 | 'MBID resolving', 6 | 'release label', 7 | ] as const; 8 | 9 | /** Features which a `MetadataProvider` can have. */ 10 | export type ProviderFeature = typeof providerFeatures[number]; 11 | 12 | /** 13 | * Maps feature names to their quality rating. 14 | * 15 | * Quality values are numeric and should use {@linkcode FeatureQuality}, with the following exceptions: 16 | * - `cover size`: Median image height in pixels 17 | * - `duration precision`: {@linkcode DurationPrecision} 18 | */ 19 | export type FeatureQualityMap = Partial>; 20 | 21 | /** General quality rating of a {@linkcode ProviderFeature} which has no specific quality scale. */ 22 | export enum FeatureQuality { 23 | MISSING = -20, 24 | BAD = -10, 25 | UNKNOWN = 0, 26 | EXPENSIVE, 27 | PRESENT, 28 | GOOD, 29 | } 30 | 31 | /** Precision of durations. */ 32 | export enum DurationPrecision { 33 | UNKNOWN = 0, 34 | SECONDS, 35 | S_OR_MS, 36 | MS, 37 | US, 38 | } 39 | -------------------------------------------------------------------------------- /providers/iTunes/api_types.ts: -------------------------------------------------------------------------------- 1 | export type Artist = { 2 | wrapperType: 'artist'; 3 | artistType: 'Artist'; 4 | artistName: string; 5 | artistLinkUrl: string; 6 | artistId: number; 7 | amgArtistId: number; 8 | primaryGenreName: string; 9 | primaryGenreId: number; 10 | }; 11 | 12 | export type Collection = { 13 | wrapperType: 'collection'; 14 | collectionType: 'Album'; 15 | artistId: number; 16 | collectionId: number; 17 | amgArtistId: number; 18 | artistName: string; 19 | collectionName: string; 20 | collectionCensoredName: string; 21 | // Various artists have no URL 22 | artistViewUrl?: string; 23 | collectionViewUrl: string; 24 | artworkUrl60: string; 25 | artworkUrl100: string; 26 | collectionPrice?: number; 27 | collectionExplicitness: Explicitness; 28 | contentAdvisoryRating?: 'Explicit'; 29 | trackCount: number; 30 | copyright: string; 31 | country: string; 32 | currency: string; 33 | releaseDate: string; 34 | primaryGenreName: string; 35 | }; 36 | 37 | export type Track = { 38 | wrapperType: 'track'; 39 | kind: Kind; 40 | artistId: number; 41 | collectionId: number; 42 | trackId: number; 43 | artistName: string; 44 | collectionName: string; 45 | trackName: string; 46 | collectionCensoredName: string; 47 | trackCensoredName: string; 48 | artistViewUrl: string; 49 | collectionViewUrl: string; 50 | trackViewUrl: string; 51 | previewUrl: string; 52 | artworkUrl30: string; 53 | artworkUrl60: string; 54 | artworkUrl100: string; 55 | collectionPrice: number; 56 | trackPrice: number; 57 | releaseDate: string; 58 | collectionExplicitness: Explicitness; 59 | trackExplicitness: Explicitness; 60 | discCount: number; 61 | discNumber: number; 62 | trackCount: number; 63 | trackNumber: number; 64 | trackTimeMillis: number; 65 | country: string; 66 | currency: string; 67 | primaryGenreName: string; 68 | isStreamable?: boolean; 69 | }; 70 | 71 | export type Explicitness = 'clean' | 'explicit' | 'notExplicit'; 72 | 73 | export type Kind = 74 | | 'album' 75 | | 'artist' 76 | | 'book' 77 | | 'coached-audio' 78 | | 'feature-movie' 79 | | 'interactive-booklet' 80 | | 'music-video' 81 | | 'pdf podcast' 82 | | 'podcast-episode' 83 | | 'software-package' 84 | | 'song' 85 | | 'tv-episode'; 86 | 87 | export type Result = { 88 | resultCount: number; 89 | results: Array; 90 | }; 91 | 92 | export type ReleaseResult = Result; 93 | -------------------------------------------------------------------------------- /providers/iTunes/mod.test.ts: -------------------------------------------------------------------------------- 1 | import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; 2 | import { stubProviderLookups } from '@/providers/test_stubs.ts'; 3 | import { assert } from 'std/assert/assert.ts'; 4 | import { afterAll, describe } from '@std/testing/bdd'; 5 | import { assertSnapshot } from '@std/testing/snapshot'; 6 | 7 | import iTunesProvider from './mod.ts'; 8 | 9 | describe('iTunes provider', () => { 10 | const itunes = new iTunesProvider(makeProviderOptions()); 11 | const lookupStub = stubProviderLookups(itunes); 12 | 13 | describeProvider(itunes, { 14 | urls: [{ 15 | description: 'Apple Music album URL', 16 | url: new URL('https://music.apple.com/de/album/1705742568'), 17 | id: { type: 'album', id: '1705742568', region: 'DE' }, 18 | isCanonical: true, 19 | }, { 20 | description: 'Apple Music album URL with implicit region', 21 | url: new URL('https://music.apple.com/album/1705742568'), 22 | id: { type: 'album', id: '1705742568', region: 'US' }, 23 | }, { 24 | description: 'Apple Music album URL with slug', 25 | url: new URL('https://music.apple.com/de/album/all-will-be-changed/1705742568'), 26 | id: { type: 'album', id: '1705742568', region: 'DE', slug: 'all-will-be-changed' }, 27 | }, { 28 | description: 'Apple Music artist URL', 29 | url: new URL('https://music.apple.com/gb/artist/136975'), 30 | id: { type: 'artist', id: '136975', region: 'GB' }, 31 | isCanonical: true, 32 | }, { 33 | description: 'Apple Music artist URL with slug', 34 | url: new URL('https://music.apple.com/gb/artist/the-beatles/136975'), 35 | id: { type: 'artist', id: '136975', region: 'GB', slug: 'the-beatles' }, 36 | }, { 37 | description: 'Apple Music artist URL with slug and tracking parameters', 38 | url: new URL('https://music.apple.com/us/artist/saint-motel/301341347?uo=4'), 39 | id: { type: 'artist', id: '301341347', region: 'US', slug: 'saint-motel' }, 40 | }, { 41 | description: 'Apple Music song URL', 42 | url: new URL('https://music.apple.com/gb/song/1772318408'), 43 | id: { type: 'song', id: '1772318408', region: 'GB' }, 44 | isCanonical: true, 45 | }, { 46 | description: 'Apple Music song URL with slug', 47 | url: new URL('https://music.apple.com/fr/song/wet-cheese-delirium-2015-remaster/973594909'), 48 | id: { type: 'song', id: '973594909', region: 'FR', slug: 'wet-cheese-delirium-2015-remaster' }, 49 | }, { 50 | description: 'iTunes legacy album URL', 51 | url: new URL('https://itunes.apple.com/gb/album/id1722294645'), 52 | id: { type: 'album', id: '1722294645', region: 'GB' }, 53 | }, { 54 | description: 'iTunes legacy album URL with implicit region', 55 | url: new URL('https://itunes.apple.com/album/id1722294645'), 56 | id: { type: 'album', id: '1722294645', region: 'US' }, 57 | }, { 58 | description: 'Apple Music geo. album URL', 59 | url: new URL('https://geo.music.apple.com/album/1135913516'), 60 | id: { type: 'album', id: '1135913516', region: 'US' }, 61 | }, { 62 | description: 'iTunes geo. album URL', 63 | url: new URL('https://geo.itunes.apple.com/album/id1135913516'), 64 | id: { type: 'album', id: '1135913516', region: 'US' }, 65 | }], 66 | releaseLookup: [{ 67 | description: 'multi-disc download with video tracks', 68 | release: new URL('https://music.apple.com/gb/album/a-night-at-the-opera-deluxe-edition/1441458047'), 69 | assert: async (release, ctx) => { 70 | await assertSnapshot(ctx, release); 71 | assert(release.media.length === 2, 'Release should have multiple discs'); 72 | assert(release.media[1].tracklist[6].type === 'video', 'Track 2.7 should be a video'); 73 | assert(release.externalLinks[0].types?.includes('paid download'), 'Release should be downloadable'); 74 | assert(release.info.providers[0].lookup.region === 'GB', 'Lookup should use the region from the URL'); 75 | }, 76 | }], 77 | }); 78 | 79 | afterAll(() => { 80 | lookupStub.restore(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /providers/iTunes/regions.ts: -------------------------------------------------------------------------------- 1 | // Availability of Apple Media Services: https://support.apple.com/en-us/118205 2 | // https://www.apple.com/newsroom/2020/04/apple-services-now-available-in-more-countries-around-the-world/ (167 regions with Apple Music) 3 | 4 | export const availableRegions = [ 5 | 'AE', 6 | 'AG', 7 | 'AI', 8 | 'AM', 9 | 'AO', 10 | 'AR', 11 | 'AT', 12 | 'AU', 13 | 'AZ', 14 | 'BA', 15 | 'BB', 16 | 'BE', 17 | 'BF', // only iTunes but not Apple Music 18 | 'BG', 19 | 'BH', 20 | 'BJ', 21 | 'BM', 22 | 'BN', // only iTunes but not Apple Music 23 | 'BO', 24 | 'BR', 25 | 'BS', 26 | 'BT', 27 | 'BW', 28 | 'BY', 29 | 'BZ', 30 | 'CA', 31 | 'CD', 32 | 'CG', 33 | 'CH', 34 | 'CI', 35 | 'CL', 36 | 'CM', 37 | 'CN', 38 | 'CO', 39 | 'CR', 40 | 'CV', 41 | 'CY', 42 | 'CZ', 43 | 'DE', 44 | 'DK', 45 | 'DM', 46 | 'DO', 47 | 'DZ', 48 | 'EC', 49 | 'EE', 50 | 'EG', 51 | 'ES', 52 | 'FI', 53 | 'FJ', 54 | 'FM', 55 | 'FR', 56 | 'GA', 57 | 'GB', 58 | 'GD', 59 | 'GE', 60 | 'GH', 61 | 'GM', 62 | 'GR', 63 | 'GT', 64 | 'GW', 65 | 'GY', 66 | 'HK', 67 | 'HN', 68 | 'HR', 69 | 'HU', 70 | 'ID', 71 | 'IE', 72 | 'IL', 73 | 'IN', 74 | 'IQ', 75 | 'IS', 76 | 'IT', 77 | 'JM', 78 | 'JO', 79 | 'JP', 80 | 'KE', 81 | 'KG', 82 | 'KH', 83 | 'KN', 84 | 'KR', 85 | 'KW', 86 | 'KY', 87 | 'KZ', 88 | 'LA', 89 | 'LB', 90 | 'LC', 91 | 'LK', 92 | 'LR', 93 | 'LT', 94 | 'LU', 95 | 'LV', 96 | 'LY', 97 | 'MA', 98 | 'MD', 99 | 'ME', 100 | 'MG', 101 | 'MK', 102 | 'ML', 103 | 'MM', 104 | 'MN', 105 | 'MO', 106 | 'MR', 107 | 'MS', 108 | 'MT', 109 | 'MU', 110 | 'MV', 111 | 'MW', 112 | 'MX', 113 | 'MY', 114 | 'MZ', 115 | 'NA', 116 | 'NE', 117 | 'NG', 118 | 'NI', 119 | 'NL', 120 | 'NO', 121 | 'NP', 122 | 'NZ', 123 | 'OM', 124 | 'PA', 125 | 'PE', 126 | 'PG', 127 | 'PH', 128 | 'PL', 129 | 'PT', 130 | 'PY', 131 | 'QA', 132 | 'RO', 133 | 'RS', 134 | 'RU', 135 | 'RW', 136 | 'SA', 137 | 'SB', 138 | 'SC', 139 | 'SE', 140 | 'SG', 141 | 'SI', 142 | 'SK', 143 | 'SL', 144 | 'SN', 145 | 'SR', 146 | 'SV', 147 | 'SZ', 148 | 'TC', 149 | 'TD', 150 | 'TH', 151 | 'TJ', 152 | 'TM', 153 | 'TN', 154 | 'TO', 155 | 'TR', 156 | 'TT', 157 | 'TW', 158 | 'TZ', 159 | 'UA', 160 | 'UG', 161 | 'US', 162 | 'UY', 163 | 'UZ', 164 | 'VC', 165 | 'VE', 166 | 'VG', 167 | 'VN', 168 | 'VU', 169 | 'XK', 170 | 'YE', 171 | 'ZA', 172 | 'ZM', 173 | 'ZW', 174 | ]; 175 | -------------------------------------------------------------------------------- /providers/mod.ts: -------------------------------------------------------------------------------- 1 | import { appInfo } from '@/app.ts'; 2 | import { dataDir } from '@/config.ts'; 3 | import type { ProviderPreferences } from '@/harmonizer/types.ts'; 4 | import { ProviderRegistry } from './registry.ts'; 5 | 6 | import BandcampProvider from './Bandcamp/mod.ts'; 7 | import BeatportProvider from './Beatport/mod.ts'; 8 | import DeezerProvider from './Deezer/mod.ts'; 9 | import iTunesProvider from './iTunes/mod.ts'; 10 | import MusicBrainzProvider from './MusicBrainz/mod.ts'; 11 | import SpotifyProvider from './Spotify/mod.ts'; 12 | import TidalProvider from './Tidal/mod.ts'; 13 | 14 | /** Registry with all supported providers. */ 15 | export const providers = new ProviderRegistry({ 16 | appInfo: appInfo, 17 | dataDir: dataDir, 18 | }); 19 | 20 | // Register all providers which should be used. 21 | providers.addMultiple( 22 | MusicBrainzProvider, 23 | DeezerProvider, 24 | iTunesProvider, 25 | SpotifyProvider, 26 | TidalProvider, 27 | BandcampProvider, 28 | BeatportProvider, 29 | ); 30 | 31 | /** Internal names of providers which are enabled by default (for GTIN lookups). */ 32 | export const defaultProviders = new Set( 33 | providers.filterInternalNamesByCategory('default'), 34 | ); 35 | 36 | /** Recommended default preferences which sort providers by quality. */ 37 | export const defaultProviderPreferences: ProviderPreferences = { 38 | labels: providers.sortNamesByQuality('release label'), 39 | length: providers.sortNamesByQuality('duration precision'), 40 | images: providers.sortNamesByQuality('cover size'), 41 | externalId: providers.sortNamesByQuality('MBID resolving'), 42 | }; 43 | -------------------------------------------------------------------------------- /providers/template.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-unused-vars 2 | /** 3 | * Template for a new provider implementation. 4 | * 5 | * Each required property is documented in the abstract base class. 6 | * 7 | * Complex provider entity type definitions should be defined in a separate module. 8 | */ 9 | 10 | import type { EntityId, HarmonyRelease } from '@/harmonizer/types.ts'; 11 | import { MetadataProvider, ReleaseLookup } from '@/providers/base.ts'; 12 | import { FeatureQualityMap } from '@/providers/features.ts'; 13 | 14 | // Providers which use an API should extend `MetadataApiProvider` instead. 15 | export default class TemplateProvider extends MetadataProvider { 16 | // TODO: Fill out all properties and implement the required methods. 17 | readonly name = 'Template'; 18 | 19 | readonly supportedUrls = new URLPattern({ 20 | hostname: 'www.example.com', 21 | pathname: '/:type(artist|release)/:id', 22 | }); 23 | 24 | // TODO: Also try to override optional properties which are (or return) empty arrays/objects in the base class. 25 | override readonly features: FeatureQualityMap = {}; 26 | 27 | readonly entityTypeMap = { 28 | artist: '', 29 | release: '', 30 | }; 31 | 32 | readonly releaseLookup = TemplateReleaseLookup; 33 | 34 | constructUrl(entity: EntityId): URL { 35 | throw new Error('Method not implemented.'); 36 | } 37 | } 38 | 39 | // Providers which use an API should extend `ReleaseApiLookup` instead. 40 | export class TemplateReleaseLookup extends ReleaseLookup { 41 | constructReleaseApiUrl(): URL | undefined { 42 | throw new Error('Method not implemented.'); 43 | } 44 | 45 | getRawRelease(): Promise { 46 | throw new Error('Method not implemented.'); 47 | } 48 | 49 | convertRawRelease(rawRelease: Release): Promise { 50 | throw new Error('Method not implemented.'); 51 | } 52 | } 53 | 54 | // TODO: Type of raw release data from the provider (for example an API result). 55 | export type Release = unknown; 56 | -------------------------------------------------------------------------------- /providers/test_stubs.ts: -------------------------------------------------------------------------------- 1 | import { downloadMode, loadResponse, saveResponse } from '@/utils/fetch_stub.ts'; 2 | import { urlToFilePath } from '@/utils/file_path.ts'; 3 | import { stub } from '@std/testing/mock'; 4 | import type { CacheOptions } from 'snap-storage'; 5 | import type { MetadataApiProvider, MetadataProvider } from './base.ts'; 6 | 7 | /** Prevents the given provider from retrieving an API access token. */ 8 | export function stubTokenRetrieval(provider: MetadataApiProvider) { 9 | return stub( 10 | provider, 11 | // @ts-ignore-error -- Private method is not visible in TS, but accessible in JS. 12 | 'cachedAccessToken', 13 | () => Promise.resolve('dummy token'), 14 | ); 15 | } 16 | 17 | /** 18 | * Stubs {@linkcode MetadataProvider.fetchSnapshot} to load the response from a cache instead of making a network request. 19 | * 20 | * Only in {@linkcode downloadMode} the resource will be fetched and written to the cache (as a file). 21 | */ 22 | export function stubProviderLookups(provider: MetadataProvider, cacheDir = 'testdata') { 23 | return stub( 24 | provider, 25 | // @ts-ignore-error -- Private method is not visible in TS, but accessible in JS. 26 | 'fetchSnapshot', 27 | async function (input: string | URL, options?: CacheOptions) { 28 | const path = await urlToFilePath(new URL(input), { baseDir: cacheDir }); 29 | let response: Response; 30 | 31 | if (downloadMode) { 32 | response = await provider.fetch(input, options?.requestInit); 33 | if (options?.responseMutator) { 34 | response = await options.responseMutator(response); 35 | } 36 | await saveResponse(response.clone(), path); 37 | } else { 38 | response = await loadResponse(path); 39 | } 40 | 41 | return { 42 | content: response, 43 | // Dummy data, should not be relevant in this context. 44 | timestamp: 0, 45 | isFresh: downloadMode, 46 | contentHash: '', 47 | path: '', 48 | }; 49 | }, 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /server/components/AlternativeValues.tsx: -------------------------------------------------------------------------------- 1 | import { ProviderIcon } from '@/server/components/ProviderIcon.tsx'; 2 | 3 | import type { ProviderName } from '@/harmonizer/types.ts'; 4 | import { uniqueMappedValues } from '@/utils/record.ts'; 5 | 6 | export interface AlternativeValueProps { 7 | property: (root: Root) => Value | undefined; 8 | display?: (value: Value) => unknown; 9 | identifier?: (value: Value) => string; 10 | } 11 | 12 | export function setupAlternativeValues(providerMap: Record | undefined) { 13 | if (!providerMap) { 14 | return () => <>; 15 | } 16 | 17 | return function AlternativeValues({ property, display, identifier }: AlternativeValueProps) { 18 | const uniqueValues = uniqueMappedValues(providerMap, property, identifier); 19 | if (uniqueValues.length > 1) { 20 | return ( 21 |
    22 | {uniqueValues.map( 23 | ([value, providerNames]) => ( 24 |
  • 25 | {display ? display(value) : value} 26 | {providerNames.map((name) => )} 27 |
  • 28 | ), 29 | )} 30 |
31 | ); 32 | } else { 33 | return <>; 34 | } 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /server/components/ArtistCredit.tsx: -------------------------------------------------------------------------------- 1 | import type { ArtistCreditName } from '@/harmonizer/types.ts'; 2 | import { LinkedEntity } from '@/server/components/LinkedEntity.tsx'; 3 | 4 | export function ArtistCredit({ artists, plainText = false }: { artists: ArtistCreditName[]; plainText?: boolean }) { 5 | const lastIndex = artists.length - 1; 6 | 7 | return ( 8 | 9 | {artists.map((artist, index) => { 10 | const displayName = artist.creditedName ?? artist.name; 11 | return ( 12 | <> 13 | {plainText ? displayName : } 14 | {artist.joinPhrase ?? (index !== lastIndex && (index === lastIndex - 1 ? ' & ' : ', '))} 15 | 16 | ); 17 | })} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /server/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from 'preact'; 2 | import { IS_BROWSER } from 'fresh/runtime.ts'; 3 | 4 | export function Button(props: JSX.HTMLAttributes) { 5 | return ( 6 | 35 | )} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /server/islands/ReleaseSeeder.tsx: -------------------------------------------------------------------------------- 1 | import InputWithOverlay from '@/server/components/InputWithOverlay.tsx'; 2 | import { SpriteIcon } from '@/server/components/SpriteIcon.tsx'; 3 | 4 | import { createReleaseSeed } from '@/musicbrainz/seeding.ts'; 5 | import { checkSetting, getSetting } from '@/server/settings.ts'; 6 | import { preferArray } from 'utils/array/scalar.js'; 7 | 8 | import type { HarmonyRelease } from '@/harmonizer/types.ts'; 9 | 10 | export function ReleaseSeeder({ release, sourceUrl, targetUrl, projectUrl }: { 11 | release: HarmonyRelease; 12 | projectUrl: string; 13 | sourceUrl?: string; 14 | targetUrl: string; 15 | }) { 16 | const seederSourceUrl = sourceUrl ? new URL(sourceUrl) : undefined; 17 | const isUpdate = targetUrl.endsWith('/edit'); 18 | const seed = createReleaseSeed(release, { 19 | projectUrl: new URL(projectUrl), 20 | redirectUrl: (seederSourceUrl && checkSetting('seeder.redirect', true)) 21 | ? new URL('release/actions', seederSourceUrl) 22 | : undefined, 23 | seederUrl: seederSourceUrl, 24 | isUpdate, 25 | annotation: { 26 | availability: checkSetting('annotation.availability', false), 27 | copyright: checkSetting('annotation.copyright', true), 28 | textCredits: checkSetting('annotation.credits', true), 29 | }, 30 | }); 31 | 32 | return ( 33 |
39 | {Object.entries(seed).flatMap(([key, valueOrValues]) => { 40 | return preferArray(valueOrValues).map((value) => ); 41 | })} 42 | 46 | 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /server/logging.ts: -------------------------------------------------------------------------------- 1 | import { inDevMode } from '@/config.ts'; 2 | import { blue, bold, green, magenta, red, yellow } from 'std/fmt/colors.ts'; 3 | import { ConsoleHandler } from 'std/log/console_handler.ts'; 4 | import type { LevelName } from 'std/log/levels.ts'; 5 | import { setup } from 'std/log/setup.ts'; 6 | 7 | setup({ 8 | handlers: { 9 | default: new ConsoleHandler('DEBUG', { 10 | formatter: ({ levelName, loggerName, msg }) => `${loggerName} [${color(levelName)}] ${msg}`, 11 | // Disable coloring of the whole formatted message. 12 | useColors: false, 13 | }), 14 | request: new ConsoleHandler('DEBUG', { 15 | formatter: ({ msg, args: [req] }) => `${magenta((req as Request).method)} ${msg}`, 16 | // Disable coloring of the whole formatted message. 17 | useColors: false, 18 | }), 19 | }, 20 | loggers: { 21 | 'harmony.lookup': { 22 | handlers: ['default'], 23 | level: 'DEBUG', // temporary 24 | }, 25 | 'harmony.mbid': { 26 | handlers: ['default'], 27 | level: 'DEBUG', // temporary 28 | }, 29 | 'harmony.provider': { 30 | handlers: ['default'], 31 | level: inDevMode ? 'DEBUG' : 'INFO', 32 | }, 33 | 'harmony.server': { 34 | handlers: ['default'], 35 | level: 'INFO', 36 | }, 37 | 'requests': { 38 | handlers: ['request'], 39 | level: inDevMode ? 'INFO' : 'WARN', 40 | }, 41 | }, 42 | }); 43 | 44 | function color(level: string): string { 45 | switch (level as LevelName) { 46 | case 'DEBUG': 47 | return green(level); 48 | case 'INFO': 49 | return blue(level); 50 | case 'WARN': 51 | return yellow(level); 52 | case 'ERROR': 53 | return red(level); 54 | case 'CRITICAL': 55 | return bold(red(level)); 56 | default: 57 | return level; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/main.ts: -------------------------------------------------------------------------------- 1 | // Automatically load .env environment variable file and configure logger (before anything else). 2 | import 'std/dotenv/load.ts'; 3 | import './logging.ts'; 4 | 5 | import { shortRevision } from '@/config.ts'; 6 | import { start } from 'fresh/server.ts'; 7 | import { getLogger } from 'std/log/get_logger.ts'; 8 | import manifest from './fresh.gen.ts'; 9 | 10 | const log = getLogger('harmony.server'); 11 | log.info(`Revision: ${shortRevision}`); 12 | 13 | const abortController = new AbortController(); 14 | const isWindows = Deno.build.os === 'windows'; 15 | 16 | // Attempt to gracefully close the server on SIGINT or SIGTERM. 17 | for (const signal of ['SIGINT', 'SIGTERM'] as const) { 18 | // On Windows only "SIGINT" (CTRL+C) and "SIGBREAK" (CTRL+Break) are supported. 19 | if (isWindows && signal !== 'SIGINT') break; 20 | 21 | Deno.addSignalListener(signal, () => { 22 | log.info(`App received '${signal}', aborting...`); 23 | abortController.abort(signal); 24 | }); 25 | } 26 | 27 | // Ignore SIGHUP, which otherwise kills the process. 28 | if (!isWindows) { 29 | Deno.addSignalListener('SIGHUP', () => { 30 | log.warn(`App received 'SIGHUP', ignoring.`); 31 | }); 32 | } 33 | 34 | // Instantiate Fresh HTTP server. 35 | await start(manifest, { server: { signal: abortController.signal } }); 36 | 37 | log.info('Fresh server and all its connections were closed.'); 38 | 39 | // Explicitly exit, otherwise dev servers would restart on file changes. 40 | Deno.exit(); 41 | -------------------------------------------------------------------------------- /server/permalink.ts: -------------------------------------------------------------------------------- 1 | import type { ReleaseInfo } from '@/harmonizer/types.ts'; 2 | import { isDefined } from '@/utils/predicate.ts'; 3 | 4 | /** Encodes the given release info into release lookup state query parameters. */ 5 | export function encodeReleaseLookupState(info: ReleaseInfo): URLSearchParams { 6 | const providersLookedUpByGtin = info.providers.filter((provider) => provider.lookup.method === 'gtin'); 7 | const providersLookedUpById = info.providers.filter((provider) => provider.lookup.method === 'id'); 8 | const usedRegion = info.providers.map((provider) => provider.lookup.region).find(isDefined); 9 | const cacheTimestamps = info.providers.map((provider) => provider.cacheTime).filter(isDefined); 10 | 11 | // Add provider IDs for all providers which were looked up by ID or URL. 12 | const state = new URLSearchParams( 13 | providersLookedUpById.map((provider) => [provider.internalName, provider.id]), 14 | ); 15 | if (providersLookedUpByGtin.length) { 16 | // In an ideal world, a GTIN is just a number, but we have providers where zero-padding matters. 17 | // By choosing the variant with the most zeros, more GTIN lookups should succeed on first try. 18 | // This is crucial to make permalinks as efficient as possible by using only cached requests. 19 | const gtinVariantsByLength = providersLookedUpByGtin 20 | .map((provider) => provider.lookup.value) 21 | .sort((a, b) => b.length - a.length); 22 | state.append('gtin', gtinVariantsByLength[0]); 23 | // Add all enabled providers which were looked up by GTIN (with empty provider ID value). 24 | for (const provider of providersLookedUpByGtin) { 25 | state.append(provider.internalName, ''); 26 | } 27 | } 28 | // If a region has been used for lookup, it should be the same for all providers. 29 | if (usedRegion) { 30 | state.append('region', usedRegion); 31 | } 32 | // Maximum timestamp can be used to load the latest snapshot up to this timestamp for each provider. 33 | state.append('ts', Math.max(...cacheTimestamps).toFixed(0)); 34 | 35 | return state; 36 | } 37 | 38 | /** 39 | * Creates a release lookup permalink for the given release info. 40 | * 41 | * Domain and base path of the given URL will be used. 42 | */ 43 | export function createReleasePermalink(info: ReleaseInfo, baseUrl: URL): URL { 44 | const permalink = new URL('release', baseUrl); 45 | permalink.search = encodeReleaseLookupState(info).toString(); 46 | 47 | return permalink; 48 | } 49 | -------------------------------------------------------------------------------- /server/routes/_app.tsx: -------------------------------------------------------------------------------- 1 | import Footer from '@/server/components/Footer.tsx'; 2 | import { NavigationBar } from '@/server/components/NavigationBar.tsx'; 3 | 4 | import { defineApp } from 'fresh/server.ts'; 5 | 6 | export default defineApp((_req, ctx) => { 7 | // OpenGraph image URL must be absolute. 8 | const logoUrl = new URL('/harmony-logo.svg', ctx.url); 9 | const isLandingPage = ctx.url.pathname === '/'; 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | Harmony 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {!isLandingPage && } 27 | 28 |