├── .dockerignore ├── .editorconfig ├── .eslintrc ├── .github ├── FUNDING.yml ├── linters │ ├── .gitleaks.toml │ └── .jscpd.json └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── TEST.md ├── banner.js ├── cli.js ├── conf.json ├── freyr.sh ├── media ├── demo.cast ├── demo.gif ├── logo.cast └── logo.gif ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── async_queue.js ├── cli_server.js ├── file_mgr.js ├── filter_parser.js ├── freyr.js ├── p_flatten.js ├── parse_range.js ├── services │ ├── apple_music.js │ ├── deezer.js │ ├── spotify.js │ └── youtube.js ├── stack_logger.js ├── stream_utils.js ├── symbols.js ├── text_utils.js └── walkr.js ├── test ├── default.json ├── index.js └── urify.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .gitignore 3 | 4 | node_modules/ 5 | media/ 6 | 7 | stage/ 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "globals": { 8 | "globalThis": false 9 | }, 10 | "parserOptions": { 11 | "sourceType": "module", 12 | "ecmaVersion": "latest", 13 | "ecmaFeatures": { 14 | "modules": true 15 | }, 16 | "requireConfigFile": false 17 | }, 18 | "extends": [ 19 | "eslint:recommended" 20 | ], 21 | "plugins": [ 22 | "prettier" 23 | ], 24 | "ignorePatterns": [ 25 | "**/*.json" 26 | ], 27 | "rules": { 28 | "prettier/prettier": [ 29 | 1, 30 | { 31 | "trailingComma": "all", 32 | "printWidth": 130, 33 | "bracketSpacing": false, 34 | "arrowParens": "avoid", 35 | "singleQuote": true 36 | } 37 | ], 38 | "no-empty": 0, 39 | "no-cond-assign": 0, 40 | "no-sparse-arrays": 0, 41 | "no-unused-vars": [ 42 | 1, 43 | { 44 | "argsIgnorePattern": "^_", 45 | "varsIgnorePattern": "^_" 46 | } 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | custom: 4 | - https://commerce.coinbase.com/checkout/466d703a-fbd7-41c9-8366-9bdd3e240755 5 | ko_fi: miraclx 6 | liberapay: miraclx 7 | patreon: miraclx 8 | -------------------------------------------------------------------------------- /.github/linters/.gitleaks.toml: -------------------------------------------------------------------------------- 1 | [allowlist] 2 | paths = ['conf.toml'] 3 | -------------------------------------------------------------------------------- /.github/linters/.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": 0, 3 | "reporters": [ 4 | "consoleFull" 5 | ], 6 | "ignore": [ 7 | "**/.github/workflows/tests.yml" 8 | ], 9 | "absolute": true 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: publish 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v[0-9]+.[0-9]+.[0-9]+*' 8 | 9 | jobs: 10 | npm: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '16.x' 20 | registry-url: 'https://registry.npmjs.org' 21 | 22 | - run: npm ci 23 | - run: npm publish 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 26 | 27 | docker: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Checkout Repository 32 | uses: actions/checkout@v4 33 | 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8 36 | 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 39 | 40 | - name: Log in to Docker Hub 41 | uses: docker/login-action@42d299face0c5c43a0487c477f595ac9cf22f1a7 42 | with: 43 | username: ${{ secrets.DOCKER_USERNAME }} 44 | password: ${{ secrets.DOCKER_PASSWORD }} 45 | 46 | - name: Extract metadata (tags, labels) for Docker 47 | id: meta 48 | uses: docker/metadata-action@e5622373a38e60fb6d795a4421e56882f2d7a681 49 | with: 50 | images: freyrcli/freyrjs 51 | tags: | 52 | type=ref,event=tag 53 | type=semver,pattern={{raw}} 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@7f9d37fa544684fb73bfe4835ed7214c255ce02b 57 | with: 58 | context: . 59 | push: true 60 | platforms: linux/amd64,linux/arm64 61 | tags: ${{ steps.meta.outputs.tags }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | cache-from: type=gha 64 | cache-to: type=gha,mode=max 65 | 66 | release: 67 | runs-on: ubuntu-latest 68 | needs: [ npm, docker ] 69 | 70 | steps: 71 | - name: Checkout Repository 72 | uses: actions/checkout@v4 73 | with: 74 | # fetch tags for cargo ws publish 75 | # might be a simple `fetch-tags: true` option soon, see https://github.com/actions/checkout/pull/579 76 | fetch-depth: 0 77 | 78 | - name: Bootstrap 79 | run: | 80 | git config user.name github-actions 81 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 82 | GIT_CURRENT_TAG="${GITHUB_REF#refs/tags/}" 83 | echo "::notice::Current Git Tag: \"${GIT_CURRENT_TAG}\"" 84 | echo "GIT_CURRENT_TAG=${GIT_CURRENT_TAG}" >> "$GITHUB_ENV" 85 | GIT_CURRENT_TAG_COMMIT="$(git rev-list -n1 "${GIT_CURRENT_TAG}")" 86 | GIT_PREVIOUS_REF="$(git describe --tags --abbrev=0 "${GIT_CURRENT_TAG_COMMIT}^1" 2>/dev/null || git rev-list --max-parents=0 HEAD)" 87 | echo "::notice::Previous Git Tag / Ref: \"${GIT_PREVIOUS_REF}\"" 88 | echo "GIT_PREVIOUS_REF=${GIT_PREVIOUS_REF}" >> "$GITHUB_ENV" 89 | echo "FREYR_VERSION=${GIT_CURRENT_TAG#v}" >> "$GITHUB_ENV" 90 | 91 | - name: Extract release notes 92 | id: extract-release-notes 93 | uses: ffurrer2/extract-release-notes@c24866884b7a0d2fd2095be2e406b6f260479da8 94 | 95 | - name: Create release 96 | uses: actions/create-release@v1 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | with: 100 | tag_name: ${{ env.GIT_CURRENT_TAG }} 101 | release_name: ${{ env.GIT_CURRENT_TAG }} 102 | body: | 103 | [![][npm-badge]][npm-url] [![][docker-badge]][docker-url] [![][github-badge]][github-url] 104 | 105 | ## What's changed? 106 | 107 | ${{ steps.extract-release-notes.outputs.release_notes }} 108 | 109 | **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ env.GIT_PREVIOUS_REF }}...${{ env.GIT_CURRENT_TAG }} 110 | 111 | [npm-url]: https://www.npmjs.com/package/freyr/v/${{ env.FREYR_VERSION }} 112 | [npm-badge]: https://img.shields.io/badge/npm-gray?logo=npm 113 | [docker-url]: https://hub.docker.com/r/freyrcli/freyrjs/tags?name=v${{ env.FREYR_VERSION }} 114 | [docker-badge]: https://img.shields.io/badge/docker-gray?logo=docker 115 | [github-url]: https://github.com/miraclx/freyr-js/releases/tag/v${{ env.FREYR_VERSION }} 116 | [github-badge]: https://img.shields.io/badge/github-gray?logo=github 117 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | 4 | on: 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | schedule: 9 | - cron: '0 0 * * WED,SAT' # 00:00 on Wednesdays and Saturdays, weekly. 10 | 11 | jobs: 12 | up-to-date: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Install Dependencies via Yarn 20 | run: | 21 | rm -rf node_modules 22 | yarn install --frozen-lockfile 23 | 24 | - name: Install Dependencies via NPM 25 | run: | 26 | rm -rf node_modules 27 | npm ci 28 | 29 | fmt: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - name: Checkout Repository 34 | uses: actions/checkout@v4 35 | 36 | - name: Install Dependencies 37 | run: npm ci 38 | 39 | - name: Run ESLint Check 40 | run: npx eslint --max-warnings 0 . 41 | 42 | docker-build: 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - name: Checkout Repository 47 | uses: actions/checkout@v4 48 | 49 | - name: Set up Docker Buildx 50 | uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 51 | 52 | - name: Build and push Docker image 53 | uses: docker/build-push-action@7f9d37fa544684fb73bfe4835ed7214c255ce02b 54 | with: 55 | context: . 56 | tags: freyr-dev:latest 57 | cache-from: type=gha 58 | cache-to: type=gha,mode=max 59 | outputs: type=docker,dest=/tmp/freyr-dev.tar 60 | 61 | - name: Upload Artifact 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: freyr-dev 65 | path: /tmp/freyr-dev.tar 66 | 67 | service-test: 68 | runs-on: ubuntu-latest 69 | strategy: 70 | fail-fast: false 71 | matrix: 72 | service: 73 | - { id: spotify, name: Spotify } 74 | - { id: apple-music, name: "Apple Music", slug: apple_music } 75 | - { id: deezer, name: Deezer } 76 | test: 77 | - { type: track, name: Track } 78 | - { type: album, name: Album } 79 | - { type: artist, name: Artist } 80 | - { type: playlist, name: Playlist } 81 | 82 | name: ${{ matrix.service.id }}-${{ matrix.test.type }} 83 | needs: docker-build 84 | steps: 85 | - name: Checkout Repository 86 | uses: actions/checkout@v4 87 | 88 | - name: Install Dependencies 89 | run: npm ci 90 | 91 | - name: Set up Docker Buildx 92 | uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 93 | 94 | - name: Download Artifact 95 | uses: actions/download-artifact@v4 96 | with: 97 | name: freyr-dev 98 | path: /tmp 99 | 100 | - name: Load Docker Image 101 | run: | 102 | docker load --input /tmp/freyr-dev.tar 103 | docker image ls -a 104 | 105 | - name: ${{ matrix.service.name }} - Download ${{ matrix.test.name }} 106 | env: 107 | DOCKER_ARGS: "--user root" 108 | run: npm test -- --docker freyr-dev ${{ matrix.service.slug || matrix.service.id }}.${{ matrix.test.type }} 109 | 110 | docker-publish: 111 | runs-on: ubuntu-latest 112 | permissions: 113 | pull-requests: write 114 | 115 | steps: 116 | - name: Checkout Repository 117 | uses: actions/checkout@v4 118 | 119 | - name: Get Git SHAs 120 | id: get-shas 121 | run: | 122 | BASE_SHA=$( echo ${{ github.event.pull_request.base.sha || github.sha }} | head -c7 ) 123 | echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" 124 | HEAD_SHA=$( echo ${{ github.event.pull_request.head.sha || github.sha }} | head -c7 ) 125 | echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" 126 | 127 | - name: Get Docker Tag 128 | id: docker-tagger-spec 129 | run: | 130 | if [[ "${{ github.event_name }}" == 'push' ]]; then 131 | echo "spec=type=ref,event=branch" >> "$GITHUB_OUTPUT" 132 | elif [[ "${{ github.event_name }}" == 'pull_request' ]]; then 133 | echo "spec=type=ref,event=pr" >> "$GITHUB_OUTPUT" 134 | fi 135 | 136 | - name: Extract Metadata (tags, labels) For Docker 137 | id: docker-meta 138 | uses: docker/metadata-action@e5622373a38e60fb6d795a4421e56882f2d7a681 139 | with: 140 | images: freyrcli/freyrjs-git 141 | tags: | 142 | ${{ steps.docker-tagger-spec.outputs.spec }} 143 | type=raw,value=${{ steps.get-shas.outputs.head_sha }} 144 | 145 | - name: Extract Tag For Report 146 | id: tag-for-report 147 | if: github.event_name == 'pull_request' 148 | run: | 149 | PR_TAG=$( echo "${{ steps.docker-meta.outputs.tags }}" | head -1 | sed 's/freyrcli\/freyrjs-git://g' ) 150 | echo "tag=$PR_TAG" >> "$GITHUB_OUTPUT" 151 | 152 | - name: Report Docker Image Build Status 153 | uses: marocchino/sticky-pull-request-comment@39c5b5dc7717447d0cba270cd115037d32d28443 154 | if: github.event_name == 'pull_request' 155 | with: 156 | message: | 157 |
158 | 159 | --- 160 | 161 | 🐋 🤖 162 | 163 | 🔃 164 | 165 | **A docker image for this PR is being built!** 166 | 167 | ```console 168 | docker pull freyrcli/freyrjs-git:${{ steps.tag-for-report.outputs.tag }} 169 | ``` 170 | 171 | | [**Base (${{ github.event.pull_request.base.ref }})**][base-url] | [![](https://img.shields.io/docker/image-size/freyrcli/freyrjs-git/${{ steps.get-shas.outputs.base_sha }}?color=gray&label=%20&logo=docker)][base-url] | 172 | | :-: | - | 173 | 174 | --- 175 | 176 |
177 | What's this? 178 | 179 | This docker image is a self-contained sandbox that includes all the patches made in this PR. Allowing others to easily use your patches without waiting for it to get merged and released officially. 180 | 181 | For more context, see https://github.com/miraclx/freyr-js#docker-development. 182 | 183 |
184 |
185 | 186 | [base-url]: https://hub.docker.com/r/freyrcli/freyrjs-git/tags?name=${{ steps.get-shas.outputs.base_sha }} 187 | 188 | - name: Set up QEMU 189 | uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8 190 | 191 | - name: Set up Docker Buildx 192 | uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 193 | 194 | - name: Log in to Docker Hub 195 | uses: docker/login-action@42d299face0c5c43a0487c477f595ac9cf22f1a7 196 | with: 197 | username: ${{ secrets.DOCKER_USERNAME }} 198 | password: ${{ secrets.DOCKER_PASSWORD }} 199 | 200 | - name: Build and push Docker image 201 | uses: docker/build-push-action@7f9d37fa544684fb73bfe4835ed7214c255ce02b 202 | with: 203 | context: . 204 | push: true 205 | platforms: linux/amd64,linux/arm64 206 | tags: ${{ steps.docker-meta.outputs.tags }} 207 | labels: ${{ steps.docker-meta.outputs.labels }} 208 | cache-from: type=gha 209 | cache-to: type=gha,mode=max 210 | 211 | - name: Report Docker Image Build Status 212 | uses: marocchino/sticky-pull-request-comment@39c5b5dc7717447d0cba270cd115037d32d28443 213 | if: github.event_name == 'pull_request' 214 | with: 215 | message: | 216 |
217 | 218 | --- 219 | 220 | 🐋 🤖 221 | 222 | **A docker image for this PR has been built!** 223 | 224 | ```console 225 | docker pull freyrcli/freyrjs-git:${{ steps.tag-for-report.outputs.tag }} 226 | ``` 227 | 228 | | [**Base (${{ github.event.pull_request.base.ref }})**][base-url] | [![](https://img.shields.io/docker/image-size/freyrcli/freyrjs-git/${{ steps.get-shas.outputs.base_sha }}?color=gray&label=%20&logo=docker)][base-url] | 229 | | :-: | - | 230 | | [**This Patch**][pr-url] | [![](https://img.shields.io/docker/image-size/freyrcli/freyrjs-git/${{ steps.tag-for-report.outputs.tag }}?color=gray&label=%20&logo=docker)][pr-url] | 231 | 232 | [![][compare-img]][compare-url] 233 | 234 | --- 235 | 236 |
237 | What's this? 238 | 239 | This docker image is a self-contained sandbox that includes all the patches made in this PR. Allowing others to easily use your patches without waiting for it to get merged and released officially. 240 | 241 | For more context, see https://github.com/miraclx/freyr-js#docker-development. 242 | 243 |
244 |
245 | 246 | [base-url]: https://hub.docker.com/r/freyrcli/freyrjs-git/tags?name=${{ steps.get-shas.outputs.base_sha }} 247 | [pr-url]: https://hub.docker.com/r/freyrcli/freyrjs-git/tags?name=${{ steps.tag-for-report.outputs.tag }} 248 | [compare-img]: https://img.shields.io/badge/%20-compare-gray?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CiAgIDxwYXRoIGZpbGw9IiNmZmZmZmYiIGQ9Ik0zLDFDMS44OSwxIDEsMS44OSAxLDNWMTRDMSwxNS4xMSAxLjg5LDE2IDMsMTZINVYxNEgzVjNIMTRWNUgxNlYzQzE2LDEuODkgMTUuMTEsMSAxNCwxSDNNOSw3QzcuODksNyA3LDcuODkgNyw5VjExSDlWOUgxMVY3SDlNMTMsN1Y5SDE0VjEwSDE2VjdIMTNNMTgsN1Y5SDIwVjIwSDlWMThIN1YyMEM3LDIxLjExIDcuODksMjIgOSwyMkgyMEMyMS4xMSwyMiAyMiwyMS4xMSAyMiwyMFY5QzIyLDcuODkgMjEuMTEsNyAyMCw3SDE4TTE0LDEyVjE0SDEyVjE2SDE0QzE1LjExLDE2IDE2LDE1LjExIDE2LDE0VjEySDE0TTcsMTNWMTZIMTBWMTRIOVYxM0g3WiIgLz4KPC9zdmc+ 249 | [compare-url]: https://portal.slim.dev/home/diff/dockerhub%3A%2F%2Fdockerhub.public%2Ffreyrcli%2Ffreyrjs-git%3A${{ steps.tag-for-report.outputs.tag }}#file-system 250 | 251 | linter: 252 | runs-on: ubuntu-latest 253 | 254 | steps: 255 | - name: Checkout Repository 256 | uses: actions/checkout@v4 257 | with: 258 | # Full git history is needed to get a proper list of changed files within `super-linter` 259 | fetch-depth: 0 260 | 261 | - name: Install Dependencies 262 | run: npm ci 263 | 264 | - name: Lint Code Base 265 | uses: github/super-linter@v5 266 | env: 267 | VALIDATE_ALL_CODEBASE: false 268 | DEFAULT_BRANCH: master 269 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 270 | 271 | JSCPD_CONFIG_FILE: .jscpd.json 272 | GITLEAKS_CONFIG_FILE: .gitleaks.toml 273 | VALIDATE_JAVASCRIPT_ES: false 274 | VALIDATE_JAVASCRIPT_STANDARD: false 275 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore local package files 2 | node_modules/ 3 | 4 | # ignore binaries 5 | bins/ 6 | 7 | # ignore stage for testing 8 | stage/ 9 | 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.10.3] - 2024-01-14 11 | 12 | - Implemented automated authentication for Apple Music. , 13 | - Fixed the YouTube Music logic for sourcing tracks. 14 | - Tracks from Deezer now encode copyright information. 15 | - Sort metadata (sonm, soar, soal) are now embedded in the output file. 16 | - Tracks from Spotify now encode copyright in the format `℗ {YEAR} {LABEL}` to match Apple Music. 17 | - Non-explicit Spotify tracks are now tagged "Inoffensive". 18 | - Fix the Apple Music uri format for tracks. 19 | - Fix the total disc number count. 20 | 21 | ## [0.10.2] - 2024-01-01 22 | 23 | - Ensure tracks maintain their order from collections to playlist file output. 24 | - Support non-latin letters in source search. 25 | - Failure to acquire an audio source is now handled gracefully. 26 | - Updated Apple Music access token. , 27 | 28 | ## [0.10.1] - 2023-08-08 29 | 30 | - Added support for Apple Music song URLs - `https://music.apple.com/us/song/1699712652`. 31 | - Embed a locale in the URLs generated from parsing Deezer URLs. 32 | 33 | ## [0.10.0] - 2023-08-08 34 | 35 | - Changed Spotify credentials, introduced migrations to mitigate any complications. , , 36 | - Improved the YouTube Music track weighing logic, we should get 30% more accurate results. 37 | - Freyr now supports paginated track artists. 38 | - Accented words like `Solidarité` now get properly normalized, helping more accurate lookups. 39 | - Fix bug with Apple Music & Deezer URI parser. , 40 | - Freyr now treats binaries in `bins/{posix,windows}` as being of higher priority than those in `PATH`. 41 | - Freyr now properly handles tracks that have no copyright information. 42 | - Freyr now properly checks the base dir instead of the current working dir for existing tracks. 43 | - Updated logic for extracting source feeds from yt-dlp's response. 44 | - Freyr now auto-disables the progress bar when it detects the absence of a compatible TTY, avoiding errors wherever possible. 45 | - Allow overriding the atomicparsley binary used with the `ATOMIC_PARSLEY_PATH` environment variable. 46 | - Updated `AtomicParsley` in the Docker images, fixing a class of errors. 47 | - Ignore yt-dlp warnings that could cause hard errors when parsing its response. 48 | - Fixed YouTube accuracy calculation. , 49 | 50 | ## [0.9.0] - 2022-12-18 51 | 52 | - BREAKING: replaced `-D, --downloader` with `-S, --source`, introduced the `-D, --check-dir` flag. 53 | - BREAKING: replaced the `.downloader.order` entry in the config file with `.downloader.sources`. 54 | - BREAKING: freyr no longer uses the temp directory by default to cache assets. 55 | - BREAKING: freyr now persists the cached assets across runs, this will grow over time, but you can clear it at will. 56 | - Replaced native `ffmpeg` with bundled Wasm version. 57 | - Implemented Apple Music pagination. , 58 | - Implemented ability to check for track existence in other directories. 59 | - Allow excluding download sources. 60 | - Use correct cover art file extension. 61 | - Simplified the banner to 8-bit instead of the 24-bit truecolor version. 62 | - Add support for Docker Desktop, or generic NAS with Docker support. 63 | - Fix race condition potentially resulting in file corruption when two identical tracks are downloaded at the same time. 64 | - Persist configuration options in the user config file. 65 | - Stripped HTML tags from playlist descriptions. 66 | - Fix `urify` subcommand with Spotify URLs. 67 | - Fix YouTube feed sourcing logic after dependency update. 68 | - Update minimum Node.js version to `v16`. , , 69 | - Remove the temporary image downloaded when an error is detected. 70 | - Revamp the test runner. , 71 | - Removed unimplemented features. 72 | - Updated Apple Music access token. 73 | 74 | ## [0.8.1] - 2022-08-04 75 | 76 | - Ensure maximum compatibility with axios when npm fails to install an expected version. 77 | 78 | ## [0.8.0] - 2022-08-04 79 | 80 | - Refactored the Dockerfile, and reduced the docker image size by 23%. 81 | - Manually compile `AtomicParsley` during docker build to allow for maximum platform support. 82 | - Add Mac M1 support to the docker image. 83 | - Made docker build faster by caching and unbinding nondependent layers. , 84 | - Fix `yarn install` not ahering to dependency overrides. 85 | - Add ability to disable the progressbar. 86 | - Remove persistent `tty` writing for normal logs. Allowing `stdout` piping for everything except the progressbar. 87 | - Fix long standing issue with freyr seeming frozen on exit. 88 | - Upgraded to ES6 Modules. 89 | - Introduced the pushing of docker images for each PR. , 90 | - Introduced a test runner, with local reproducible builds. 91 | - Redesigned the auth page a bit. 92 | - Introduced CI checks for formatting. 93 | - Updated dependencies. 94 | - Removed some unused dependencies. , 95 | 96 | ## [0.7.0] - 2022-06-09 97 | 98 | - Updated Apple Music access key. 99 | - Simplified the output of using the `-v, --version`. 100 | - Dropped extra version in the header. 101 | - Fixed issue with docker build not bundling dependencies. 102 | - Update dependencies. 103 | 104 | ## [0.6.0] - 2022-02-21 105 | 106 | - All dependencies updated. 107 | - Support `"single"` specification in `"type"` filter. 108 | - Address hanging problem on exit. 109 | - Touch up final stats. 110 | - Fix `AND` and `OR` behavior when dealing with filters. 111 | - Added the `CHANGELOG.md` file to track project changes. 112 | - Introduced CI runtime checks. 113 | - Introduced CI lint checks. 114 | - Automated the CI release process. 115 | - Support either `AtomicParsley` or `atomicparsley`. 116 | - Documents the dependency on YouTube for sourcing audio. 117 | - Documentation now links to file index of an example library – . 118 | 119 | ## [0.5.0] - 2022-01-27 120 | 121 | > Release Page: 122 | 123 | [unreleased]: https://github.com/miraclx/freyr-js/compare/v0.10.3...HEAD 124 | [0.10.3]: https://github.com/miraclx/freyr-js/compare/v0.10.2...v0.10.3 125 | [0.10.2]: https://github.com/miraclx/freyr-js/compare/v0.10.1...v0.10.2 126 | [0.10.1]: https://github.com/miraclx/freyr-js/compare/v0.10.0...v0.10.1 127 | [0.10.0]: https://github.com/miraclx/freyr-js/compare/v0.9.0...v0.10.0 128 | [0.9.0]: https://github.com/miraclx/freyr-js/compare/v0.8.1...v0.9.0 129 | [0.8.1]: https://github.com/miraclx/freyr-js/compare/v0.8.0...v0.8.1 130 | [0.8.0]: https://github.com/miraclx/freyr-js/compare/v0.7.0...v0.8.0 131 | [0.7.0]: https://github.com/miraclx/freyr-js/compare/v0.6.0...v0.7.0 132 | [0.6.0]: https://github.com/miraclx/freyr-js/compare/v0.5.0...v0.6.0 133 | [0.5.0]: https://github.com/miraclx/freyr-js/releases/tag/v0.5.0 134 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.2.0-alpine3.16 as installer 2 | 3 | COPY package.json yarn.lock /freyr/ 4 | WORKDIR /freyr 5 | ARG YOUTUBE_DL_SKIP_PYTHON_CHECK=1 6 | RUN yarn install --prod --frozen-lockfile \ 7 | && test -x node_modules/youtube-dl-exec/bin/yt-dlp 8 | 9 | FROM golang:1.20.4-alpine3.16 as prep 10 | 11 | # hadolint ignore=DL3018 12 | RUN apk add --no-cache git g++ make cmake linux-headers 13 | COPY --from=installer /freyr/node_modules /freyr/node_modules 14 | RUN go install github.com/tj/node-prune@1159d4c \ 15 | && node-prune --include '*.map' /freyr/node_modules \ 16 | && node-prune /freyr/node_modules \ 17 | # todo! revert to upstream when https://github.com/wez/atomicparsley/pull/63 is merged and a release is cut 18 | && git clone --branch 20230114.175602.21bde60 --depth 1 https://github.com/miraclx/atomicparsley /atomicparsley \ 19 | && cmake -S /atomicparsley -B /atomicparsley \ 20 | && cmake --build /atomicparsley --config Release 21 | 22 | FROM alpine:3.20.1 as base 23 | 24 | # hadolint ignore=DL3018 25 | RUN apk add --no-cache bash nodejs python3 \ 26 | && find /usr/lib/python3* \ 27 | \( -type d -name __pycache__ -o -type f -name '*.whl' \) \ 28 | -exec rm -r {} \+ 29 | COPY --from=prep /atomicparsley/AtomicParsley /bin/AtomicParsley 30 | 31 | COPY . /freyr 32 | COPY --from=prep /freyr/node_modules /freyr/node_modules 33 | 34 | # hadolint ignore=DL4006 35 | RUN addgroup -g 1000 freyr \ 36 | && adduser -DG freyr freyr \ 37 | && echo freyr:freyr | chpasswd \ 38 | && ln -s /freyr/cli.js /bin/freyr \ 39 | && mkdir /data \ 40 | && chown -R freyr:freyr /freyr /data 41 | WORKDIR /freyr 42 | USER freyr 43 | 44 | WORKDIR /data 45 | VOLUME /data 46 | 47 | ENTRYPOINT ["/freyr/freyr.sh"] 48 | CMD ["--help"] 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /TEST.md: -------------------------------------------------------------------------------- 1 | 2 | # freyr testing 3 | 4 | freyr is bundled with its own flexibly customizable test runner. 5 | 6 | - To run all tests 7 | 8 | ```console 9 | npm test -- --all 10 | ``` 11 | 12 | - To run just Spotify tests 13 | 14 | ```console 15 | npm test -- spotify 16 | ``` 17 | 18 | - To run just Apple Music artist tests 19 | 20 | ```console 21 | npm test -- apple_music.artist 22 | ``` 23 | 24 | - You can use a custom test suite (see the [default suite](https://github.com/miraclx/freyr-js/blob/master/test/default.json) for an example) 25 | 26 | ```console 27 | npm test -- --all --suite ./special_cases.json 28 | ``` 29 | 30 | - And optionally, you can run the tests inside a freyr docker container 31 | 32 | ```console 33 | npm test -- deezer --docker freyr-dev:latest 34 | ``` 35 | 36 | - You can customize the working directory for storing the tracks and logs 37 | 38 | ```console 39 | npm test -- spotify.track --name run-1 --stage ./test-runs 40 | ``` 41 | 42 | ## `npm test -- --help` 43 | 44 | ```console 45 | freyr-test 46 | ---------- 47 | Usage: freyr-test [options] [[.]...] 48 | 49 | Utility for testing the Freyr CLI 50 | 51 | Options: 52 | 53 | SERVICE spotify / apple_music / deezer 54 | TYPE track / album / artist / playlist 55 | 56 | --all run all tests 57 | --suite use a specific test suite (json) 58 | --docker run tests in a docker container 59 | --help show this help message 60 | 61 | Enviroment Variables: 62 | 63 | DOCKER_ARGS arguments to pass to `docker run` 64 | 65 | Example: 66 | 67 | $ freyr-test --all 68 | runs all tests 69 | 70 | $ freyr-test spotify 71 | runs all Spotify tests 72 | 73 | $ freyr-test apple_music.album 74 | tests downloading an Apple Music album 75 | 76 | $ freyr-test spotify.track deezer.artist 77 | tests downloading a Spotify track and Deezer artist 78 | ``` 79 | -------------------------------------------------------------------------------- /banner.js: -------------------------------------------------------------------------------- 1 | import esMain from 'es-main'; 2 | 3 | const banner = [ 4 | '\x1b[38;5;63m \x1b[39m\x1b[38;5;63m \x1b[39m\x1b[38;5;33m \x1b[39m\x1b[38;5;39m \x1b[39m\x1b[38;5;39m_\x1b[39m\x1b[38;5;44m_\x1b[39m\x1b[38;5;44m_\x1b[39m\x1b[38;5;49m_\x1b[39m\x1b[38;5;49m \x1b[39m\x1b[38;5;48m \x1b[39m\x1b[38;5;83m \x1b[39m\x1b[38;5;83m \x1b[39m\x1b[38;5;118m \x1b[39m\x1b[38;5;118m \x1b[39m\x1b[38;5;154m \x1b[39m\x1b[38;5;148m \x1b[39m\x1b[38;5;184m \x1b[39m\x1b[38;5;178m \x1b[39m\x1b[38;5;214m \x1b[39m\x1b[38;5;208m \x1b[39m\x1b[38;5;208m \x1b[39m\x1b[38;5;203m \x1b[39m\x1b[38;5;203m \x1b[39m\x1b[38;5;198m \x1b[39m\x1b[38;5;199m \x1b[39m\x1b[38;5;199m \x1b[39m\x1b[38;5;164m \x1b[39m\x1b[38;5;164m \x1b[39m\x1b[38;5;129m\x1b[39m', 5 | '\x1b[38;5;63m \x1b[39m\x1b[38;5;33m \x1b[39m\x1b[38;5;33m \x1b[39m\x1b[38;5;39m/\x1b[39m\x1b[38;5;38m \x1b[39m\x1b[38;5;44m_\x1b[39m\x1b[38;5;43m_\x1b[39m\x1b[38;5;49m/\x1b[39m\x1b[38;5;48m_\x1b[39m\x1b[38;5;48m_\x1b[39m\x1b[38;5;83m_\x1b[39m\x1b[38;5;83m_\x1b[39m\x1b[38;5;118m_\x1b[39m\x1b[38;5;154m_\x1b[39m\x1b[38;5;154m_\x1b[39m\x1b[38;5;184m \x1b[39m\x1b[38;5;184m \x1b[39m\x1b[38;5;214m_\x1b[39m\x1b[38;5;214m_\x1b[39m\x1b[38;5;208m \x1b[39m\x1b[38;5;203m \x1b[39m\x1b[38;5;203m_\x1b[39m\x1b[38;5;198m_\x1b[39m\x1b[38;5;198m_\x1b[39m\x1b[38;5;199m_\x1b[39m\x1b[38;5;163m_\x1b[39m\x1b[38;5;164m_\x1b[39m\x1b[38;5;128m_\x1b[39m\x1b[38;5;129m\x1b[39m', 6 | '\x1b[38;5;63m \x1b[39m\x1b[38;5;33m \x1b[39m\x1b[38;5;39m/\x1b[39m\x1b[38;5;39m \x1b[39m\x1b[38;5;44m/\x1b[39m\x1b[38;5;44m_\x1b[39m\x1b[38;5;49m/\x1b[39m\x1b[38;5;49m \x1b[39m\x1b[38;5;48m_\x1b[39m\x1b[38;5;83m_\x1b[39m\x1b[38;5;83m_\x1b[39m\x1b[38;5;118m/\x1b[39m\x1b[38;5;118m \x1b[39m\x1b[38;5;154m_\x1b[39m\x1b[38;5;148m \x1b[39m\x1b[38;5;184m\\\x1b[39m\x1b[38;5;178m/\x1b[39m\x1b[38;5;214m \x1b[39m\x1b[38;5;208m/\x1b[39m\x1b[38;5;208m \x1b[39m\x1b[38;5;203m/\x1b[39m\x1b[38;5;203m \x1b[39m\x1b[38;5;198m/\x1b[39m\x1b[38;5;199m \x1b[39m\x1b[38;5;199m_\x1b[39m\x1b[38;5;164m_\x1b[39m\x1b[38;5;164m_\x1b[39m\x1b[38;5;129m/\x1b[39m\x1b[38;5;129m\x1b[39m', 7 | '\x1b[38;5;33m \x1b[39m\x1b[38;5;33m/\x1b[39m\x1b[38;5;39m \x1b[39m\x1b[38;5;38m_\x1b[39m\x1b[38;5;44m_\x1b[39m\x1b[38;5;43m/\x1b[39m\x1b[38;5;49m \x1b[39m\x1b[38;5;48m/\x1b[39m\x1b[38;5;48m \x1b[39m\x1b[38;5;83m \x1b[39m\x1b[38;5;83m/\x1b[39m\x1b[38;5;118m \x1b[39m\x1b[38;5;154m \x1b[39m\x1b[38;5;154m_\x1b[39m\x1b[38;5;184m_\x1b[39m\x1b[38;5;184m/\x1b[39m\x1b[38;5;214m \x1b[39m\x1b[38;5;214m/\x1b[39m\x1b[38;5;208m_\x1b[39m\x1b[38;5;203m/\x1b[39m\x1b[38;5;203m \x1b[39m\x1b[38;5;198m/\x1b[39m\x1b[38;5;198m \x1b[39m\x1b[38;5;199m/\x1b[39m\x1b[38;5;163m \x1b[39m\x1b[38;5;164m \x1b[39m\x1b[38;5;128m \x1b[39m\x1b[38;5;129m \x1b[39m\x1b[38;5;93m\x1b[39m', 8 | '\x1b[38;5;33m/\x1b[39m\x1b[38;5;39m_\x1b[39m\x1b[38;5;39m/\x1b[39m\x1b[38;5;44m \x1b[39m\x1b[38;5;44m/\x1b[39m\x1b[38;5;49m_\x1b[39m\x1b[38;5;49m/\x1b[39m\x1b[38;5;48m \x1b[39m\x1b[38;5;83m \x1b[39m\x1b[38;5;83m \x1b[39m\x1b[38;5;118m\\\x1b[39m\x1b[38;5;118m_\x1b[39m\x1b[38;5;154m_\x1b[39m\x1b[38;5;148m_\x1b[39m\x1b[38;5;184m/\x1b[39m\x1b[38;5;178m\\\x1b[39m\x1b[38;5;214m_\x1b[39m\x1b[38;5;208m_\x1b[39m\x1b[38;5;208m,\x1b[39m\x1b[38;5;203m \x1b[39m\x1b[38;5;203m/\x1b[39m\x1b[38;5;198m_\x1b[39m\x1b[38;5;199m/\x1b[39m\x1b[38;5;199m \x1b[39m\x1b[38;5;164m \x1b[39m\x1b[38;5;164m \x1b[39m\x1b[38;5;129m \x1b[39m\x1b[38;5;129m \x1b[39m\x1b[38;5;93m\x1b[39m', 9 | '\x1b[38;5;33m \x1b[39m\x1b[38;5;39m \x1b[39m\x1b[38;5;38m \x1b[39m\x1b[38;5;44m \x1b[39m\x1b[38;5;43m \x1b[39m\x1b[38;5;49m \x1b[39m\x1b[38;5;48m \x1b[39m\x1b[38;5;48m \x1b[39m\x1b[38;5;83m \x1b[39m\x1b[38;5;83m \x1b[39m\x1b[38;5;118m \x1b[39m\x1b[38;5;154m \x1b[39m\x1b[38;5;154m \x1b[39m\x1b[38;5;184m \x1b[39m\x1b[38;5;184m/\x1b[39m\x1b[38;5;214m_\x1b[39m\x1b[38;5;214m_\x1b[39m\x1b[38;5;208m_\x1b[39m\x1b[38;5;203m_\x1b[39m\x1b[38;5;203m/\x1b[39m\x1b[38;5;198m\x1b[39m', 10 | ]; 11 | 12 | export default banner; 13 | 14 | if (esMain(import.meta)) console.log(banner.join('\n')); 15 | 16 | /* 17 | To generate the banner: 18 | $ unset COLORTERM 19 | $ figlet -fslant freyr \ 20 | | lolcat -p 0.5 -S 35 -f \ 21 | | sed 's/\\/\\\\/g;s/\x1b/\\x1b/g' 22 | 23 | To record logo: 24 | $ GIFSICLE_OPTS=--lossy=80 asciicast2gif \ 25 | -t tango -s 2 -w 28 -h 6 logo.cast logo.gif 26 | */ 27 | -------------------------------------------------------------------------------- /conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "hostname": "localhost", 4 | "port": 36346, 5 | "useHttps": false 6 | }, 7 | "concurrency": { 8 | "queries": 1, 9 | "tracks": 1, 10 | "trackStage": 6, 11 | "downloader": 4, 12 | "encoder": 6, 13 | "embedder": 10 14 | }, 15 | "opts": { 16 | "netCheck": true, 17 | "attemptAuth": true, 18 | "autoOpenBrowser": true 19 | }, 20 | "filters": [], 21 | "dirs": { 22 | "output": ".", 23 | "check": [], 24 | "cache": { 25 | "path": "", 26 | "keep": true 27 | } 28 | }, 29 | "playlist": { 30 | "always": false, 31 | "append": true, 32 | "escape": true, 33 | "forceAppend": false, 34 | "dir": "", 35 | "namespace": "" 36 | }, 37 | "image": { 38 | "width": 640, 39 | "height": 640 40 | }, 41 | "downloader": { 42 | "memCache": true, 43 | "cacheSize": 209715200, 44 | "sources": [ 45 | "yt_music", 46 | "youtube" 47 | ] 48 | }, 49 | "services": { 50 | "spotify": { 51 | "clientId": "3ee9cf67e90346a191bfe735581a5aa0", 52 | "clientSecret": "14206998339649fabd3c4057b84edbf2", 53 | "refreshToken": "AQCbeNs5NiGfHa6He0BlOdoQwEIdo2lwBmefEvpvqVy8WlL2HV7rmGbb30_oaZHMDf9MgXtGjI0gV_QukL33PE8c2bGqJHeMqdHBUjKWhxOW19snJkGUiyxen8UvmDG-OP4" 54 | }, 55 | "apple_music": { 56 | "storefront": "us" 57 | }, 58 | "deezer": { 59 | "retries": 2 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /freyr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$DOCKER_DESKTOP" = "true" ]; then 4 | COLS=$(stty size 2>&- | cut -d" " -f2) 5 | ( 6 | echo 7 | echo 8 | echo 9 | echo "┌────────────────────────────────────────────────────────────┐" 10 | echo "│ You are running freyr from inside Docker Desktop │" 11 | echo "│ Click on the CLI button at the top right to access a CLI │" 12 | echo "└────────────────────────────────────────────────────────────┘" 13 | ) | ( 14 | while read -r line; do 15 | printf "%*s" "$(((COLS - ${#line}) / 2))" "" 16 | echo "$line" 17 | done 18 | ) 19 | 20 | tail -f /dev/null 21 | fi 22 | 23 | node "${FREYR_NODE_ARGS[@]}" -- "$(dirname "$0")"/cli.js "$@" 24 | -------------------------------------------------------------------------------- /media/demo.cast: -------------------------------------------------------------------------------- 1 | {"version": 2, "width": 99, "height": 26, "timestamp": 1613746806, "env": {"SHELL": "/usr/bin/zsh", "TERM": "xterm-256color"}} 2 | [0.120553, "o", "\u001b[1m\u001b[32m~/stage\u001b[0m $ "] 3 | [1.121204, "o", "f"] 4 | [1.301878, "o", "r"] 5 | [1.421117, "o", "e"] 6 | [1.601266, "o", "y"] 7 | [1.608875, "o", "r"] 8 | [1.74921, "o", " "] 9 | [2.001767, "o", "spotify:track:54bFM56PmE4YLRnqpW6Tha"] 10 | [2.621668, "o", "\r\n"] 11 | [3.622358, "o", "\u001b[38;2;95;37;250m \u001b[39m\u001b[38;2;71;57;254m \u001b[39m\u001b[38;2;50;79;253m \u001b[39m\u001b[38;2;31;104;247m \u001b[39m\u001b[38;2;17;129;237m_\u001b[39m\u001b[38;2;7;154;222m_\u001b[39m\u001b[38;2;1;178;203m_\u001b[39m\u001b[38;2;1;200;181m_\u001b[39m\u001b[38;2;6;220;157m \u001b[39m\u001b[38;2;15;235;132m \u001b[39m\u001b[38;2;29;246;107m \u001b[39m\u001b[38;2;47;253;82m \u001b[39m\u001b[38;2;68;254;60m \u001b[39m\u001b[38;2;92;251;40m \u001b[39m\u001b[38;2;117;242;23m \u001b[39m\u001b[38;2;142;229;11m \u001b[39m\u001b[38;2;167;212;3m \u001b[39m\u001b[38;2;190;192;1m \u001b[39m\u001b[38;2;211;169;3m \u001b[39m\u001b[38;2;228;144;10m \u001b[39m\u001b[38;2;242;119;22m \u001b[39m\u001b[38;2;250;94;38m \u001b[39m\u001b[38;2;254;70;58m \u001b[39m\u001b[38;2;253;49;81m \u001b[39m\u001b[38;2;247;30;105m \u001b[39m\u001b[38;2;236;16;130m \u001b[39m\u001b[38;2;221;6;156m \u001b[39m\u001b[38;2;202;1;180m \u001b[39m\u001b[38;2;180;1;202m\u001b[39m\r\n\u001b[38;2;71;57;254m \u001b[39m\u001b[38;2;50;79;253m \u001b[39m\u001b[38;2;31;104;247m \u001b[39m\u001b[38;2;17;129;237m/\u001b[39m\u001b[38;2;7;154;222m \u001b[39m\u001b[38;2;1;178;203m_\u001b[39m\u001b[38;2;1;200;181m_\u001b[39m\u001b[38;2;6;220;157m/\u001b[39m\u001b[38;2;15;235;132m_\u001b[39m\u001b[38;2;29;246;107m_\u001b[39m\u001b[38;2;47;253;82m_\u001b[39m\u001b[38;2;68;254;60m_\u001b[39m\u001b[38;2;92;251;40m_\u001b[39m\u001b[38;2;117;242;23m_\u001b[39m\u001b[38;2;142;229;11m_"] 12 | [3.62296, "o", "\u001b[39m\u001b[38;2;167;212;3m \u001b[39m\u001b[38;2;190;192;1m \u001b[39m\u001b[38;2;211;169;3m_\u001b[39m\u001b[38;2;228;144;10m_\u001b[39m\u001b[38;2;242;119;22m \u001b[39m\u001b[38;2;250;94;38m \u001b[39m\u001b[38;2;254;70;58m_\u001b[39m\u001b[38;2;253;49;81m_\u001b[39m\u001b[38;2;247;30;105m_\u001b[39m\u001b[38;2;236;16;130m_\u001b[39m\u001b[38;2;221;6;156m_\u001b[39m\u001b[38;2;202;1;180m_\u001b[39m\u001b[38;2;180;1;202m_\u001b[39m\u001b[38;2;156;6;221m\u001b[39m\r\n\u001b[38;2;50;79;253m \u001b[39m\u001b[38;2;31;104;247m \u001b[39m\u001b[38;2;17;129;237m/\u001b[39m\u001b[38;2;7;154;222m \u001b[39m\u001b[38;2;1;178;203m/\u001b[39m\u001b[38;2;1;200;181m_\u001b[39m\u001b[38;2;6;220;157m/\u001b[39m\u001b[38;2;15;235;132m \u001b[39m\u001b[38;2;29;246;107m_\u001b[39m\u001b[38;2;47;253;82m_\u001b[39m\u001b[38;2;68;254;60m_\u001b[39m\u001b[38;2;92;251;40m/\u001b[39m\u001b[38;2;117;242;23m \u001b[39m\u001b[38;2;142;229;11m_\u001b[39m\u001b[38;2;167;212;3m \u001b[39m\u001b[38;2;190;192;1m\\\u001b[39m\u001b[38;2;211;169;3m/\u001b[39m\u001b[38;2;228;144;10m \u001b[39m\u001b[38;2;242;119;22m/\u001b[39m\u001b[38;2;250;94;38m \u001b[39m\u001b[38;2;254;70;58m/\u001b[39m\u001b[38;2;253;49;81m \u001b[39m\u001b[38;2;247;30;105m/\u001b[39m\u001b[38;2;236;16;130m \u001b[39m\u001b[38;2;221;6;156m_\u001b[39m\u001b[38;2;202;1;180m_\u001b[39m\u001b[38;2;180;1;202m_\u001b[39m\u001b[38;2;156;6;221m/\u001b[39m\u001b[38;2;131;16;236m\u001b[39m\r\n\u001b[38;2;31;104"] 13 | [3.623218, "o", ";247m \u001b[39m\u001b[38;2;17;129;237m/\u001b[39m\u001b[38;2;7;154;222m \u001b[39m\u001b[38;2;1;178;203m_\u001b[39m\u001b[38;2;1;200;181m_\u001b[39m\u001b[38;2;6;220;157m/\u001b[39m\u001b[38;2;15;235;132m \u001b[39m\u001b[38;2;29;246;107m/\u001b[39m\u001b[38;2;47;253;82m \u001b[39m\u001b[38;2;68;254;60m \u001b[39m\u001b[38;2;92;251;40m/\u001b[39m\u001b[38;2;117;242;23m \u001b[39m\u001b[38;2;142;229;11m \u001b[39m\u001b[38;2;167;212;3m_\u001b[39m\u001b[38;2;190;192;1m_\u001b[39m\u001b[38;2;211;169;3m/\u001b[39m\u001b[38;2;228;144;10m \u001b[39m\u001b[38;2;242;119;22m/\u001b[39m\u001b[38;2;250;94;38m_\u001b[39m\u001b[38;2;254;70;58m/\u001b[39m\u001b[38;2;253;49;81m \u001b[39m\u001b[38;2;247;30;105m/\u001b[39m\u001b[38;2;236;16;130m \u001b[39m\u001b[38;2;221;6;156m/\u001b[39m\u001b[38;2;202;1;180m \u001b[39m\u001b[38;2;180;1;202m \u001b[39m\u001b[38;2;156;6;221m \u001b[39m\u001b[38;2;131;16;236m \u001b[39m\u001b[38;2;105;30;247m\u001b[39m\r\n\u001b[38;2;17;129;237m/\u001b[39m\u001b[38;2;7;154;222m_\u001b[39m\u001b[38;2;1;178;203m/\u001b[39m\u001b[38;2;1;200;181m \u001b[39m\u001b[38;2;6;220;157m/\u001b[39m\u001b[38;2;15;235;132m_\u001b[39m\u001b[38;2;29;246;107m/\u001b[39m\u001b[38;2;47;253;82m \u001b[39m\u001b[38;2;68;254;60m \u001b[39m\u001b[38;2;92;251;40m \u001b[39m\u001b[38;2;117;242;23m\\\u001b[39m\u001b[38;2;142;229;11m_\u001b[39m\u001b[38;2;167;212;3m_\u001b[39m\u001b[38;2;190;192;1m_\u001b[39m\u001b[38;2;211;169;3m/\u001b[39m\u001b[38;2;"] 14 | [3.623324, "o", "228;144;10m\\\u001b[39m\u001b[38;2;242;119;22m_\u001b[39m\u001b[38;2;250;94;38m_\u001b[39m\u001b[38;2;254;70;58m,\u001b[39m\u001b[38;2;253;49;81m \u001b[39m\u001b[38;2;247;30;105m/\u001b[39m\u001b[38;2;236;16;130m_\u001b[39m\u001b[38;2;221;6;156m/\u001b[39m\u001b[38;2;202;1;180m \u001b[39m\u001b[38;2;180;1;202m \u001b[39m\u001b[38;2;156;6;221m \u001b[39m\u001b[38;2;131;16;236m \u001b[39m\u001b[38;2;105;30;247m \u001b[39m\u001b[38;2;81;48;253m\u001b[39m\r\n\u001b[38;2;7;154;222m \u001b[39m\u001b[38;2;1;178;203m \u001b[39m\u001b[38;2;1;200;181m \u001b[39m\u001b[38;2;6;220;157m \u001b[39m\u001b[38;2;15;235;132m \u001b[39m\u001b[38;2;29;246;107m \u001b[39m\u001b[38;2;47;253;82m \u001b[39m\u001b[38;2;68;254;60m \u001b[39m\u001b[38;2;92;251;40m \u001b[39m\u001b[38;2;117;242;23m \u001b[39m\u001b[38;2;142;229;11m \u001b[39m\u001b[38;2;167;212;3m \u001b[39m\u001b[38;2;190;192;1m \u001b[39m\u001b[38;2;211;169;3m \u001b[39m\u001b[38;2;228;144;10m/\u001b[39m\u001b[38;2;242;119;22m_\u001b[39m\u001b[38;2;250;94;38m_\u001b[39m\u001b[38;2;254;70;58m_\u001b[39m\u001b[38;2;253;49;81m_\u001b[39m\u001b[38;2;247;30;105m/\u001b[0m v0.1.0\r\n\r\nfreyr v0.1.0 - (c) Miraculous Owonubi \r\n-------------------------------------------------------------\r\n"] 15 | [3.776653, "o", "Checking directory permissions..."] 16 | [3.787486, "o", "[done] \r\n"] 17 | [3.875959, "o", "[spotify:track:54bFM56PmE4YLRnqpW6Tha]\r\n"] 18 | [3.876851, "o", " [•] Identifying service..."] 19 | [3.878063, "o", "[Spotify]\r\n"] 20 | [3.878304, "o", " [•] Checking authentication..."] 21 | [3.882164, "o", "[authenticated]\r\n"] 22 | [3.8901, "o", " Detected [track]\r\n"] 23 | [3.890375, "o", " Obtaining track metadata..."] 24 | [4.059559, "o", "[done] \r\n"] 25 | [4.059717, "o", " ➤ Title: Therefore I Am\r\n ➤ Album: Therefore I Am\r\n ➤ Artist: Billie Eilish\r\n"] 26 | [4.059992, "o", " ➤ Year: 2020\r\n"] 27 | [4.060527, "o", " ➤ Playtime: 02:54\r\n"] 28 | [4.060717, "o", " [•] Collating...\r\n"] 29 | [4.070489, "o", " • [01 Therefore I Am]\r\n"] 30 | [4.071088, "o", " | ➤ Collating sources...\r\n"] 31 | [4.071432, "o", " | ➤ [•] YouTube Music..."] 32 | [5.072006, "o", "[success, found 7 sources]\r\n | ➤ Awaiting audiofeeds..."] 33 | [6.072021, "o", "[done] \r\n"] 34 | [6.240862, "o", "\u001b[1G"] 35 | [6.241316, "o", "\u001b[0J"] 36 | [6.241472, "o", " [•] Downloading \u001b[31m•\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m#######\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m---------------------------------------\u001b[39m\u001b[49m] [ 14%] [105.64 Kbps] (6s)\r\n | [\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [13.21 KB/92.44 KB]"] 37 | [6.263819, "o", "\u001b[3A\u001b[1G\u001b[0J"] 38 | [6.263975, "o", " [•] Downloading \u001b[36m••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m#############\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m---------------------------------\u001b[39m\u001b[49m] [ 29%] [211.28 Kbps] (2.5s)\r\n | [\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [26.41 KB/92.44 KB]"] 39 | [6.278882, "o", "\u001b[3A"] 40 | [6.279247, "o", "\u001b[1G\u001b[0J"] 41 | [6.279516, "o", " [•] Downloading \u001b[37m•••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m###############\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------------------------------\u001b[39m\u001b[49m] [ 32%] [233.22 Kbps] (2.2s)\r\n | [\u001b[42m\u001b[30m#\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [29.15 KB/92.44 KB]"] 42 | [6.291698, "o", "\u001b[3A"] 43 | [6.291889, "o", "\u001b[1G\u001b[0J"] 44 | [6.292249, "o", " [•] Downloading \u001b[30m••••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m####################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m--------------------------\u001b[39m\u001b[49m] [ 43%] [316.92 Kbps] (1.3s)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [39.62 KB/92.44 KB]"] 45 | [6.303592, "o", "\u001b[3A\u001b[1G\u001b[0J [•] Downloading \u001b[90m•••••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m####################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m--------------------------\u001b[39m\u001b[49m] [ 44%] [327.89 Kbps] (1.3s)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [40.99 KB/92.44 KB]"] 46 | [6.315445, "o", "\u001b[3A\u001b[1G\u001b[0J [•] Downloading \u001b[36m••••••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m##########################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m--------------------\u001b[39m\u001b[49m] [ 57%] [422.56 Kbps] (751ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [52.82 KB/92.44 KB]"] 47 | [6.332473, "o", "\u001b[3A\u001b[1G\u001b[0J"] 48 | [6.33286, "o", " [•] Downloading \u001b[36m•••••••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m###########################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------------------\u001b[39m\u001b[49m] [ 59%] [433.53 Kbps] (706ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [54.19 KB/92.44 KB]"] 49 | [6.341027, "o", "\u001b[3A"] 50 | [6.341192, "o", "\u001b[1G\u001b[0J [•] Downloading \u001b[33m••••••••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m#################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------------\u001b[39m\u001b[49m] [ 71%] [528.20 Kbps] (401ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [66.03 KB/92.44 KB]"] 51 | [6.347076, "o", "\u001b[3A\u001b[1G"] 52 | [6.34728, "o", "\u001b[0J [•] Downloading \u001b[34m•••••••••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m##################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m------------\u001b[39m\u001b[49m] [ 73%] [539.17 Kbps] (372ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m---------\u001b[39m\u001b[49m] [67.40 KB/92.44 KB]"] 53 | [6.359183, "o", "\u001b[3A\u001b[1G\u001b[0J"] 54 | [6.359443, "o", " [•] Downloading \u001b[33m••••••••••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m#######################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------\u001b[39m\u001b[49m] [ 86%] [633.86 Kbps] (167ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m##########\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m] [79.23 KB/92.44 KB]"] 55 | [6.371596, "o", "\u001b[3A\u001b[1G\u001b[0J"] 56 | [6.373077, "o", " [•] Downloading \u001b[30m•\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m########################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m------\u001b[39m\u001b[49m] [ 87%] [644.83 Kbps] (147ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m##########\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m] [80.60 KB/92.44 KB]"] 57 | [6.393425, "o", "\u001b[3A\u001b[1G\u001b[0J [•] Downloading \u001b[91m••\u001b[39m\r\n | • [Retrieving album art]... (chunks: 7)\r\n | [\u001b[42m\u001b[30m##############################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m] [100%] [739.50 Kbps] (0ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m##########\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m] [92.44 KB/92.44 KB]"] 58 | [6.396694, "o", "\u001b[3A\u001b[1G\u001b[0J"] 59 | [6.397593, "o", " | [✓] Got album art \r\n"] 60 | [6.499521, "o", "\u001b[1G"] 61 | [6.500095, "o", "\u001b[0J"] 62 | [6.501121, "o", " [•] Downloading \u001b[91m•••\u001b[39m"] 63 | [6.501795, "o", "\r\n"] 64 | [6.502214, "o", " | • [‘01 Therefore I Am’] (chunks: 7)"] 65 | [6.502486, "o", "\r\n"] 66 | [6.502989, "o", " | [\u001b[42m\u001b[30m#######\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m---------------------------------------\u001b[39m\u001b[49m] [ 14%] [3.34 Mbps] (6s)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [416.99 KB/2.92 MB]"] 67 | [6.519394, "o", "\u001b[3A"] 68 | [6.519673, "o", "\u001b[1G"] 69 | [6.521638, "o", "\u001b[0J [•] Downloading \u001b[37m••••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m#############\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m---------------------------------\u001b[39m\u001b[49m] [ 29%] [6.67 Mbps] (2.5s)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [833.98 KB/2.92 MB]"] 70 | [6.540683, "o", "\u001b[3A"] 71 | [6.541247, "o", "\u001b[1G"] 72 | [6.541479, "o", "\u001b[0J"] 73 | [6.54228, "o", " [•] Downloading \u001b[90m•••••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m####################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m--------------------------\u001b[39m\u001b[49m] [ 43%] [10.01 Mbps] (1.3s)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [1.25 MB/2.92 MB]"] 74 | [6.551367, "o", "\u001b[3A\u001b[1G"] 75 | [6.551868, "o", "\u001b[0J [•] Downloading \u001b[95m••••••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m####################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m--------------------------\u001b[39m\u001b[49m] [ 43%] [10.01 Mbps] (1.3s)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [1.25 MB/2.92 MB]"] 76 | [6.579723, "o", "\u001b[3A"] 77 | [6.580536, "o", "\u001b[1G\u001b[0J [•] Downloading \u001b[97m•••••••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m####################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m--------------------------\u001b[39m\u001b[49m] [ 43%] [10.14 Mbps] (1.3s)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [1.27 MB/2.92 MB]"] 78 | [6.606614, "o", "\u001b[3A"] 79 | [6.606762, "o", "\u001b[1G\u001b[0J"] 80 | [6.608215, "o", " [•] Downloading \u001b[36m••••••••\u001b[39m"] 81 | [6.608837, "o", "\r\n"] 82 | [6.60985, "o", " | • [‘01 Therefore I Am’] (chunks: 7)"] 83 | [6.614967, "o", "\r\n"] 84 | [6.615856, "o", " | [\u001b[42m\u001b[30m##########################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m--------------------\u001b[39m\u001b[49m] [ 57%] [13.34 Mbps] (751ms)"] 85 | [6.616579, "o", "\r\n"] 86 | [6.617403, "o", " | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [1.67 MB/2.92 MB]"] 87 | [6.631485, "o", "\u001b[3A"] 88 | [6.632319, "o", "\u001b[1G"] 89 | [6.634925, "o", "\u001b[0J"] 90 | [6.635747, "o", " [•] Downloading \u001b[36m•••••••••\u001b[39m"] 91 | [6.636554, "o", "\r\n"] 92 | [6.636901, "o", " | • [‘01 Therefore I Am’] (chunks: 7)"] 93 | [6.637495, "o", "\r\n"] 94 | [6.63807, "o", " | [\u001b[42m\u001b[30m###########################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------------------\u001b[39m\u001b[49m] [ 58%] [13.47 Mbps] (733ms)"] 95 | [6.638702, "o", "\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [1.68 MB/2.92 MB]"] 96 | [6.694334, "o", "\u001b[3A\u001b[1G"] 97 | [6.695357, "o", "\u001b[0J [•] Downloading \u001b[95m••••••••••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m#################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------------\u001b[39m\u001b[49m] [ 71%] [16.68 Mbps] (401ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [2.08 MB/2.92 MB]"] 98 | [6.761841, "o", "\u001b[3A\u001b[1G\u001b[0J"] 99 | [6.763401, "o", " [•] Downloading \u001b[34m•\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m#################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------------\u001b[39m\u001b[49m] [ 72%] [16.78 Mbps] (392ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-----\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [2.10 MB/2.92 MB]"] 100 | [6.796314, "o", "\u001b[3A"] 101 | [6.796783, "o", "\u001b[1G"] 102 | [6.796929, "o", "\u001b[0J"] 103 | [6.800672, "o", " [•] Downloading \u001b[94m••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m#######################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m-------\u001b[39m\u001b[49m] [ 86%] [20.02 Mbps] (167ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [2.50 MB/2.92 MB]"] 104 | [6.809772, "o", "\u001b[3A"] 105 | [6.809896, "o", "\u001b[1G\u001b[0J"] 106 | [6.810134, "o", " [•] Downloading \u001b[36m•••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m########################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m------\u001b[39m\u001b[49m] [ 86%] [20.15 Mbps] (160ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m----------\u001b[39m\u001b[49m] [2.52 MB/2.92 MB]"] 107 | [6.831488, "o", "\u001b[3A\u001b[1G\u001b[0J [•] Downloading \u001b[34m••••\u001b[39m\r\n | • [‘01 Therefore I Am’] (chunks: 7)\r\n | [\u001b[42m\u001b[30m##############################################\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m] [100%] [23.35 Mbps] (0ms)\r\n | [\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m#####\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m|\u001b[39m\u001b[49m\u001b[42m\u001b[30m##########\u001b[39m\u001b[49m\u001b[32m\u001b[39m\u001b[49m\u001b[39m\u001b[39m\u001b[49m] [2.92 MB/2.92 MB]"] 108 | [6.876557, "o", "\u001b[3A"] 109 | [6.876745, "o", "\u001b[1G\u001b[0J"] 110 | [6.876982, "o", " | [✓] Got raw track file \r\n"] 111 | [6.877622, "o", " | [•] Post Processing...\r\n"] 112 | [6.880137, "o", " [•] Download Complete\r\n [•] Embedding Metadata...\r\n"] 113 | [7.881402, "o", " • [✓] 01 Therefore I Am\r\n"] 114 | [7.881829, "o", "[•] Collation Complete\r\n========== Stats ==========\r\n"] 115 | [7.884674, "o", " [•] Runtime: [16.8s]\r\n"] 116 | [7.884976, "o", " [•] Total queries: [01]\r\n [•] Total tracks: [01]\r\n"] 117 | [7.88768, "o", " » Skipped: [00]"] 118 | [7.888286, "o", "\r\n ✓ Passed: [01]\r\n ✕ Failed: [00]\r\n [•] Output directory: [.]\r\n [•] Cover Art: cover.png (640x640)"] 119 | [7.889981, "o", "\r\n [•] Total Output size: 7.07 MB\r\n [•] Total Network Usage: 3.01 MB\r\n ♫ Media: 2.92 MB\r\n ➤ Album Art: 92.44 KB\r\n [•] Output bitrate: 320k\r\n===========================\r\n"] 120 | [7.907487, "o", "\u001b[1m\u001b[32m~/stage\u001b[0m $ "] 121 | [8.908339, "o", "t"] 122 | [9.03436, "o", "r"] 123 | [9.13621, "o", "e"] 124 | [9.282439, "o", "e"] 125 | [9.827155, "o", "\r\n"] 126 | [9.827463, "o", ".\r\n"] 127 | [9.827584, "o", "└── Billie Eilish\r\n └── Therefore I Am\r\n ├── 01 Therefore I Am.m4a\r\n └── cover.png\r\n\r\n2 directories, 2 files\r\n"] 128 | [9.827873, "o", "\u001b[1m\u001b[32m~/stage\u001b[0m $ "] 129 | [10.828562, "o", "exit\r\n"] 130 | -------------------------------------------------------------------------------- /media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miraclx/freyr-js/05c0591190660a735c53d5180fa90f28e4ee9a48/media/demo.gif -------------------------------------------------------------------------------- /media/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miraclx/freyr-js/05c0591190660a735c53d5180fa90f28e4ee9a48/media/logo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freyr", 3 | "version": "0.10.3", 4 | "description": "A versatile, service-agnostic music downloader and manager", 5 | "exports": "./src/freyr.js", 6 | "type": "module", 7 | "bin": { 8 | "freyr": "./cli.js" 9 | }, 10 | "engines": { 11 | "node": ">=16" 12 | }, 13 | "scripts": { 14 | "test": "$NODE test/index.js" 15 | }, 16 | "files": [ 17 | "src", 18 | "conf.json", 19 | "banner.js" 20 | ], 21 | "directories": { 22 | "lib": "src" 23 | }, 24 | "keywords": [ 25 | "spotify", 26 | "music-streaming", 27 | "music", 28 | "manager", 29 | "download", 30 | "track", 31 | "album", 32 | "artist", 33 | "playlist", 34 | "iTunes", 35 | "mpeg-4", 36 | "m4a", 37 | "audio", 38 | "320kbps" 39 | ], 40 | "author": { 41 | "name": "Miraculous Owonubi", 42 | "email": "omiraculous@gmail.com", 43 | "url": "https://about.me/miraclx" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git@github.com:miraclx/freyr-js.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/miraclx/freyr-js/issues" 51 | }, 52 | "license": "Apache-2.0", 53 | "dependencies": { 54 | "@ffmpeg/core": "^0.11.0", 55 | "@ffmpeg/ffmpeg": "^0.11.0", 56 | "@miraclx/spotify-web-api-node": "^5.1.0", 57 | "@yujinakayama/apple-music": "^0.4.1", 58 | "async": "^3.2.4", 59 | "bluebird": "^3.7.2", 60 | "cachedir": "^2.3.0", 61 | "commander": "^11.0.0", 62 | "conf": "^12.0.0", 63 | "cookie-parser": "^1.4.6", 64 | "cors": "^2.8.5", 65 | "country-data": "0.0.31", 66 | "es-main": "^1.0.2", 67 | "express": "^4.18.1", 68 | "file-type": "^19.0.0", 69 | "filenamify": "^6.0.0", 70 | "got": "^13.0.0", 71 | "hh-mm-ss": "^1.2.0", 72 | "html-entities": "^2.3.3", 73 | "isbinaryfile": "^5.0.0", 74 | "libxget": "^0.11.0", 75 | "lodash.merge": "^4.6.2", 76 | "lodash.mergewith": "^4.6.2", 77 | "lodash.sortby": "^4.7.0", 78 | "merge2": "^1.4.1", 79 | "minimatch": "^9.0.0", 80 | "mkdirp": "^3.0.0", 81 | "node-cache": "^5.1.2", 82 | "open": "^10.0.0", 83 | "pretty-ms": "^9.0.0", 84 | "public-ip": "^6.0.1", 85 | "spotify-uri": "^4.0.0", 86 | "stringd": "^2.2.0", 87 | "stringd-colors": "^1.10.0", 88 | "xbytes": "^1.8.0", 89 | "xprogress": "^0.20.0", 90 | "youtube-dl-exec": "^3.0.7", 91 | "yt-search": "^2.10.3" 92 | }, 93 | "devDependencies": { 94 | "eslint": "8.57.0", 95 | "eslint-plugin-prettier": "5.2.1", 96 | "prettier": "3.3.3" 97 | }, 98 | "overrides": { 99 | "public-ip": { 100 | "got": "13.0.0" 101 | }, 102 | "yt-search": { 103 | "node-fzf": { 104 | ".": "0.11.0", 105 | "cli-color": "2.0.4", 106 | "string-width": "7.2.0" 107 | } 108 | }, 109 | "@yujinakayama/apple-music": { 110 | "axios": "1.7.2" 111 | }, 112 | "@ffmpeg/ffmpeg": { 113 | "node-fetch": "2.7.0" 114 | } 115 | }, 116 | "resolutions": { 117 | "@yujinakayama/apple-music/axios": "1.7.2", 118 | "@ffmpeg/ffmpeg/node-fetch": "2.7.0", 119 | "public-ip/got": "13.0.0", 120 | "yt-search/node-fzf": "0.11.0", 121 | "yt-search/node-fzf/cli-color": "2.0.4", 122 | "yt-search/node-fzf/string-width": "7.2.0" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "labels": [ 3 | "dependencies" 4 | ], 5 | "extends": [ 6 | "config:base" 7 | ], 8 | "schedule": "every weekend", 9 | "packageRules": [ 10 | { 11 | "enabled": false, 12 | "matchFiles": [ 13 | ".github/workflows/tests.yml", 14 | ".github/workflows/publish.yml" 15 | ], 16 | "matchPackageNames": [ 17 | "docker/login-action", 18 | "docker/metadata-action", 19 | "docker/build-push-action", 20 | "docker/setup-qemu-action", 21 | "docker/setup-buildx-action", 22 | "ffurrer2/extract-release-notes", 23 | "marocchino/sticky-pull-request-comment" 24 | ] 25 | }, 26 | { 27 | "groupName": "ffmpeg-wasm", 28 | "matchPackagePrefixes": [ 29 | "@ffmpeg/" 30 | ] 31 | }, 32 | { 33 | "groupName": "eslint-plugin-prettier", 34 | "matchPackageNames": [ 35 | "prettier", 36 | "eslint-plugin-prettier" 37 | ] 38 | }, 39 | { 40 | "groupName": "actions/upload-download-artifact", 41 | "matchFiles": [ 42 | ".github/workflows/tests.yml" 43 | ], 44 | "matchPackageNames": [ 45 | "actions/upload-artifact", 46 | "actions/download-artifact" 47 | ] 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/async_queue.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names, prefer-spread */ 2 | import async from 'async'; 3 | 4 | function insulate(items) { 5 | Promise.allSettled(Array.isArray(items) ? items : [items]); 6 | return items; 7 | } 8 | 9 | const IsProvisionedProxy = Symbol('IsProvisionedProxy'); 10 | const ResourceDropSignal = Symbol('ResourceDropSignal'); 11 | 12 | export default class AsyncQueue { 13 | static debugStack = Symbol('AsyncQueueStack'); 14 | 15 | #store = { 16 | name: null, 17 | queue: null, 18 | }; 19 | 20 | /** 21 | * Creates an async queue with a defined `concurrency`. 22 | * Tasks added to the `queue` are processed in parallel (up to the `concurrency` limit). 23 | * If all available `worker`s are in progress, tasks are queued until one becomes available. 24 | * Once a `worker` completes a `task`, its promise handle is fulfilled. 25 | * @param {string} [name] A string identifier for better error handling 26 | * @param {number} [concurrency=1] An integer for determining how many cuncurrent operations to execute in parallel 27 | * @param {(data, args) => void} worker An async function for processing a queued task (default: method executor) 28 | * @example 29 | * const q = new AsyncQueue("queue0", 3); 30 | * 31 | * q.push(() => Promise.delay(5000, 'a')).then(console.log); 32 | * q.push(() => Promise.delay(2000, 'b')).then(console.log); 33 | * q.push(() => Promise.delay(3000, 'c')).then(console.log); 34 | * q.push(() => Promise.delay(500, 'd')).then(console.log); 35 | * 36 | * // [?] Result 37 | * // b 38 | * // d 39 | * // c 40 | * // a 41 | */ 42 | constructor(name, concurrency, worker) { 43 | if (typeof name === 'number') [name, concurrency] = [concurrency, name]; 44 | if (typeof name === 'function') [name, worker] = [worker, name]; 45 | if (typeof concurrency === 'function') [concurrency, worker] = [worker, concurrency]; 46 | if (typeof concurrency === 'string') [concurrency, name] = [name, concurrency]; 47 | if (name !== undefined && typeof name !== 'string') throw Error('the parameter, if specified must be a `string`'); 48 | if (concurrency !== undefined && typeof concurrency !== 'number') 49 | throw TypeError('the argument, if specified must be a `number`'); 50 | if (worker !== undefined && typeof worker !== 'function') 51 | throw TypeError('the argument, if specified must be a `function`'); 52 | this.#store.name = name || 'AsyncQueue'; 53 | this.#store.worker = worker; 54 | this.#store.queue = async.queue(({data, args}, cb) => { 55 | (async () => (worker ? worker(data, ...args) : typeof data === 'function' ? data.apply(null, args) : data))() 56 | .then(res => cb(null, res)) 57 | .catch(err => { 58 | err = Object(err); 59 | if (!err[AsyncQueue.debugStack]) 60 | Object.defineProperty(err, AsyncQueue.debugStack, { 61 | value: [], 62 | configurable: false, 63 | writable: false, 64 | enumerable: true, 65 | }); 66 | err[AsyncQueue.debugStack].push({queueName: this.#store.name, sourceTask: data, sourceArgs: args}); 67 | cb(err); 68 | }); 69 | }, concurrency || 1); 70 | } 71 | 72 | static provision(genFn, worker) { 73 | let resources = []; 74 | let fn = async (...args) => { 75 | let resource; 76 | if (args[0] === ResourceDropSignal) { 77 | while ((resource = resources.shift())) await genFn(true, resource); 78 | return; 79 | } 80 | resource = resources.shift() || (await genFn(false, null)); 81 | try { 82 | return await worker(resource, ...args); 83 | } finally { 84 | resources.push(resource); 85 | } 86 | }; 87 | fn[IsProvisionedProxy] = true; 88 | return fn; 89 | } 90 | 91 | #_register = (objects, meta, handler) => { 92 | const promises = (Array.isArray(objects) ? objects : [[objects, meta]]).map(objectBlocks => { 93 | const [data, args] = Array.isArray(objectBlocks) ? objectBlocks : [objectBlocks, meta]; 94 | return insulate( 95 | this.#store.queue[handler]({ 96 | data: insulate(data), 97 | args: insulate(args !== undefined ? (Array.isArray(args) ? args : [args]) : []), 98 | }), 99 | ); 100 | }); 101 | return Array.isArray(objects) ? promises : promises[0]; 102 | }; 103 | 104 | /** 105 | * Add a new `task` to the queue. Return a promise that fulfils on completion and rejects if an error occurs. 106 | * A second argument `meta` can define any additional data to be processed along with it. 107 | * The default worker, for example is a method executor, with this, the second argument can serve as an array of arguments. 108 | * The `objects` argument can be an array of tasks or an array of tasks and their arguments. 109 | * `q.push(task)`, `q.push(task, args)`, `q.push([task1, task2])`, `q.push([[task1, args], [task2, args]])` 110 | * @typedef {() => any} ExecFn 111 | * @param {ExecFn|ExecFn[]|Array<[ExecFn, any]>} objects 112 | * @param {any} meta 113 | * @returns {Promise} 114 | * @example 115 | * const q = new AsyncQueue("queue1", 3); 116 | * 117 | * q.push([ 118 | * [(t, v) => Promise.delay(t, v), [2500, 1]], 119 | * [(t, v) => Promise.delay(t, v), [1500, 2]], 120 | * [(t, v) => Promise.delay(t, v), [2000, 3]], 121 | * ]).map(item => item.then(val => console.log('item>', val))); 122 | * 123 | * // [?] Result 124 | * // item> 2 125 | * // item> 3 126 | * // item> 1 127 | * 128 | * const q2 = new AsyncQueue("multiplier", 4, t => Promise.delay(2000, [t, t ** 2])); 129 | * q2.push([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).map(item => 130 | * item.then(([val, srq]) => console.log(`${val}^2 = ${srq}`)) 131 | * ); 132 | * 133 | * // [?] Result 134 | * // 1^2 = 1 135 | * // 2^2 = 4 136 | * // 3^2 = 9 137 | * // 4^2 = 16 138 | * // 5^2 = 25 139 | * // 6^2 = 36 140 | * // 7^2 = 49 141 | * // 8^2 = 64 142 | * // 9^2 = 81 143 | * // 10^2 = 100 144 | */ 145 | push(objects, meta) { 146 | return this.#_register(objects, meta, 'pushAsync'); 147 | } 148 | 149 | /** 150 | * Like `this.push()` but adds a task to the front of the queue 151 | * @typedef {() => any} ExecFn 152 | * @param {ExecFn|ExecFn[]|Array<[ExecFn, any]>} objects 153 | * @param {any} meta 154 | * @returns {Promise} 155 | */ 156 | unshift(objects, meta) { 157 | return this.#_register(objects, meta, 'unshiftAsync'); 158 | } 159 | 160 | /** 161 | * Pause the processing of tasks until `resume()` is called. 162 | */ 163 | pause() { 164 | this.#store.queue.pause(); 165 | } 166 | 167 | /** 168 | * Resume task processing when the queue is paused. 169 | */ 170 | resume() { 171 | this.#store.queue.resume(); 172 | } 173 | 174 | /** 175 | * Return the number of tasks waiting to be processed 176 | */ 177 | length() { 178 | return this.#store.queue.length(); 179 | } 180 | 181 | /** 182 | * Clear pending tasks and forces the queue to go idle, cleans up any associated resources. 183 | * The queue should not be pushed back to after this method call. 184 | */ 185 | async abort() { 186 | this.#store.queue.kill(); 187 | await this.cleanup(); 188 | } 189 | 190 | async cleanup() { 191 | if (this.#store.worker && this.#store.worker[IsProvisionedProxy]) await this.#store.worker(ResourceDropSignal); 192 | } 193 | 194 | /** 195 | * Return the number of tasks currently being processed. 196 | */ 197 | running() { 198 | return this.#store.queue.running(); 199 | } 200 | 201 | /** 202 | * Get / Set the number of active workers. 203 | */ 204 | get concurrency() { 205 | return this.#store.queue.concurrency; 206 | } 207 | 208 | set concurrency(concurrency) { 209 | this.#store.queue.concurrency = concurrency; 210 | } 211 | 212 | /** 213 | * Boolean for checking if the queue is paused. 214 | */ 215 | get paused() { 216 | return this.#store.queue.paused; 217 | } 218 | 219 | /** 220 | * a boolean indicating whether or not any items have been pushed and processed by the queue. 221 | */ 222 | get started() { 223 | return this.#store.queue.started(); 224 | } 225 | 226 | /** 227 | * a boolean indicating whether or not items are waiting to be processed. 228 | */ 229 | get idle() { 230 | return this.#store.queue.idle(); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/cli_server.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import events from 'events'; 3 | 4 | import cors from 'cors'; 5 | import express from 'express'; 6 | import stringd from 'stringd'; 7 | import cookieParser from 'cookie-parser'; 8 | 9 | function wrapHTML(opts) { 10 | return stringd( 11 | ` 12 | 13 | 14 | FreyrCLI 15 | 16 | 17 | 44 | 45 | 46 | 47 |
48 |

FreyrCLI

49 |
50 | :{service} 51 |
52 |

:{msg}

53 | You can close this tab 54 |
55 | 56 | `, 57 | opts, 58 | ); 59 | } 60 | 61 | export default class AuthServer extends events.EventEmitter { 62 | #store = { 63 | port: null, 64 | hostname: null, 65 | serviceName: null, 66 | baseUrl: null, 67 | callbackRoute: null, 68 | express: null, 69 | }; 70 | 71 | constructor(opts) { 72 | super(); 73 | this.#store.port = opts.port || 36346; 74 | this.#store.hostname = opts.hostname || 'localhost'; 75 | this.#store.serviceName = opts.serviceName; 76 | this.#store.stateKey = 'auth_state'; 77 | this.#store.baseUrl = `http${opts.useHttps ? 's' : ''}://${this.#store.hostname}:${this.#store.port}`; 78 | this.#store.callbackRoute = '/callback'; 79 | this.#store.express = express().use(cors()).use(cookieParser()); 80 | } 81 | 82 | getRedirectURL() { 83 | return `${this.#store.baseUrl}${this.#store.callbackRoute}`; 84 | } 85 | 86 | async init(gFn) { 87 | return new Promise(resolve => { 88 | const server = this.#store.express 89 | .get('/', (_req, res) => { 90 | const state = crypto.randomBytes(8).toString('hex'); 91 | res.cookie(this.#store.stateKey, state); 92 | res.redirect(gFn(state)); 93 | }) 94 | .get(this.#store.callbackRoute, (req, res) => { 95 | const code = req.query.code || null; 96 | const state = req.query.state || null; 97 | const storedState = req.cookies ? req.cookies[this.#store.stateKey] : null; 98 | res.clearCookie(this.#store.stateKey); 99 | if (code == null || state === null || state !== storedState) { 100 | res.end(wrapHTML({service: this.#store.serviceName, color: '#d0190c', msg: 'Authentication Failed'})); 101 | return; 102 | } 103 | res.end(wrapHTML({service: this.#store.serviceName, color: '#1ae822;', msg: 'Successfully Authenticated'})); 104 | server.close(); 105 | this.emit('code', code); 106 | }) 107 | .listen(this.#store.port, this.#store.hostname, () => resolve(this.#store.baseUrl)); 108 | }); 109 | } 110 | 111 | async getCode() { 112 | const [code] = await events.once(this, 'code'); 113 | return code; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/file_mgr.js: -------------------------------------------------------------------------------- 1 | import {tmpdir} from 'os'; 2 | import {join, resolve, dirname} from 'path'; 3 | import {createHash, randomBytes} from 'crypto'; 4 | import {promises as fs, constants as fs_constants} from 'fs'; 5 | 6 | import esMain from 'es-main'; 7 | import {mkdirp} from 'mkdirp'; 8 | 9 | import symbols from './symbols.js'; 10 | 11 | const openfiles = {}; 12 | const removeHandlers = []; 13 | 14 | async function garbageCollect(args) { 15 | await Promise.all(removeHandlers.splice(0, Infinity).map(fn => fn(args))); 16 | } 17 | 18 | let hookedUpListeners = false; 19 | const hookupListeners = () => 20 | !hookedUpListeners && (hookedUpListeners = true) 21 | ? process.addListener('beforeExit', garbageCollect.bind(null, void 0)).addListener('exit', () => { 22 | let err = new Error('[file_mgr] Somehow, there are still files that need to be removed'); 23 | if (removeHandlers.length) throw err; 24 | }) 25 | : void 0; 26 | 27 | export default function genFile(opts) { 28 | let inner = async mode => { 29 | if ('tmpdir' in opts && opts.path) throw new Error('Cannot specify path and tmpdir'); 30 | opts = Object.assign({path: null, filename: null, dirname: null, tmpdir: true, keep: false}, opts); 31 | if (opts.path && (opts.filename || opts.dirname)) throw new Error('Cannot specify path and either filename or dirname'); 32 | if (!(opts.path || opts.filename)) opts.filename = randomBytes(8).toString('hex'); 33 | if (opts.tmpdir) 34 | if (opts.dirname) opts.dirname = join(tmpdir(), opts.dirname); 35 | else opts.dirname = tmpdir(); 36 | if (!opts.path) 37 | if (opts.filename && opts.dirname) opts.path = join(opts.dirname, opts.filename); 38 | else throw new Error('Unable to determine file path'); 39 | mode = fs_constants.O_CREAT | mode; 40 | const path = resolve(opts.path); 41 | let id = createHash('md5').update(`Ξ${mode}${path}`).digest('hex'); 42 | let file = openfiles[id]; 43 | if (!file) { 44 | await mkdirp(dirname(path)); 45 | file = openfiles[id] = {path, handle: null, refs: 1, closed: true, keep: false, writer: null}; 46 | } else file.refs += 1; 47 | if (file.closed) [file.closed, file.handle] = [false, await fs.open(path, mode)]; 48 | hookupListeners(); 49 | const garbageHandler = async ({keep} = {}) => { 50 | file.keep ||= keep !== undefined ? keep : opts.keep; 51 | if ((file.refs = Math.max(0, file.refs - 1))) return; 52 | if (file.closed) return; 53 | let handle = file.handle; 54 | delete file.handle; 55 | file.closed = true; 56 | delete openfiles[id]; 57 | await handle.close(); 58 | if (!file.keep) await fs.unlink(path); 59 | }; 60 | removeHandlers.push(garbageHandler); 61 | return { 62 | [symbols.fileId]: id, 63 | path, 64 | handle: file.handle, 65 | remove: async () => { 66 | await garbageHandler({keep: false}); 67 | removeHandlers.splice(removeHandlers.indexOf(garbageHandler), 1); 68 | }, 69 | }; 70 | }; 71 | let methods = { 72 | read: () => inner(fs_constants.O_RDONLY), 73 | /** File will be written to once, unless forcefully forgotten. */ 74 | writeOnce: async writerGen => { 75 | let fileRef = await inner(fs_constants.O_WRONLY | fs_constants.O_TRUNC); 76 | let file = openfiles[fileRef[symbols.fileId]]; 77 | await (file.writer ||= fileRef.writer = writerGen(fileRef)); 78 | return fileRef; 79 | }, 80 | open: inner, 81 | write: () => Promise.reject(new Error('not yet implemented')), 82 | }; 83 | let used = false; 84 | return Object.fromEntries( 85 | Object.entries(methods).map(([name, fn]) => [ 86 | name, 87 | async (...args) => { 88 | if (used) throw new Error(`A FileReference can only be used once`); 89 | used = true; 90 | return await fn(...args); 91 | }, 92 | ]), 93 | ); 94 | } 95 | 96 | genFile.garbageCollect = garbageCollect; 97 | 98 | async function test() { 99 | const filename = 'freyr_mgr_temp_file'; 100 | async function testMgr(args) { 101 | const file = await genFile({filename, ...args}).read(); 102 | console.log('mgr>', file); 103 | return file; 104 | } 105 | let a = await testMgr(); 106 | let b = await testMgr(); 107 | let _c = await testMgr({keep: true}); 108 | let d = await testMgr(); 109 | a.remove(); 110 | b.remove(); 111 | // _c.remove(); // calling this would negate the keep directive 112 | d.remove(); 113 | } 114 | 115 | if (esMain(import.meta)) test().catch(err => console.error('cli>', err)); 116 | -------------------------------------------------------------------------------- /src/filter_parser.js: -------------------------------------------------------------------------------- 1 | function deescapeFilterPart(filterPart) { 2 | return filterPart.replace(/{([^\s]+?)}/g, '$1'); 3 | } 4 | 5 | function exceptEscapeFromFilterPart(str, noGlob) { 6 | return new RegExp(`(?=[^{])${str}(?=[^}])`, !noGlob ? 'g' : ''); 7 | } 8 | 9 | function dissociate(str, sep) { 10 | let match; 11 | return (match = str.match(exceptEscapeFromFilterPart(sep, true))) 12 | ? [str.slice(0, match.index), str.slice(match.index + 1)] 13 | : [str]; 14 | } 15 | 16 | function parseFilters(filterLine) { 17 | return filterLine 18 | ? filterLine 19 | .split(exceptEscapeFromFilterPart(',')) 20 | .map(part => dissociate(part, '=').map(sect => deescapeFilterPart(sect.replace(/^\s*["']?|["']?\s*$/g, '')))) 21 | : []; 22 | } 23 | 24 | export default function parseSearchFilter(pattern) { 25 | let [query, filters] = dissociate(pattern, '@').map(str => str.trim()); 26 | if (!filters) [query, filters] = [filters, query]; 27 | filters = parseFilters(filters); 28 | if (!query && (filters[0] || []).length === 1) [query] = filters.shift(); 29 | return {query: query ? deescapeFilterPart(query) : '*', filters: Object.fromEntries(filters)}; 30 | } 31 | -------------------------------------------------------------------------------- /src/freyr.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | import _sortBy from 'lodash.sortby'; 3 | 4 | import symbols from './symbols.js'; 5 | import {YouTube, YouTubeMusic} from './services/youtube.js'; 6 | import Deezer from './services/deezer.js'; 7 | import Spotify from './services/spotify.js'; 8 | import AppleMusic from './services/apple_music.js'; 9 | 10 | export default class FreyrCore { 11 | static ENGINES = [Deezer, Spotify, AppleMusic, YouTube, YouTubeMusic]; 12 | 13 | static getBitrates() { 14 | return Array.from( 15 | new Set(this.ENGINES.reduce((stack, engine) => stack.concat(engine[symbols.meta].BITRATES || []), [])), 16 | ).sort((a, b) => (typeof a === 'string' || a > b ? 1 : -1)); 17 | } 18 | 19 | static getEngineMetas(ops) { 20 | return this.ENGINES.map(engine => (ops || (v => v))(engine[symbols.meta])); 21 | } 22 | 23 | static identifyService(content) { 24 | return this.ENGINES.find(engine => 25 | engine[symbols.meta].PROPS.isQueryable ? content.match(engine[symbols.meta].VALID_URL) : undefined, 26 | ); 27 | } 28 | 29 | static collateSources() { 30 | return this.ENGINES.filter(engine => engine[symbols.meta].PROPS.isSourceable); 31 | } 32 | 33 | static sortSources(includeOrder, exclude) { 34 | includeOrder = includeOrder ? (Array.isArray(includeOrder) ? includeOrder : [includeOrder]) : []; 35 | return _sortBy(this.collateSources(), source => 36 | (index => (index < 0 ? Infinity : index))(includeOrder.indexOf(source[symbols.meta].ID)), 37 | ).filter(source => !~exclude.indexOf(source[symbols.meta].ID)); 38 | } 39 | 40 | static parseURI(url) { 41 | const service = this.identifyService(url); 42 | if (!service) return null; 43 | return service.prototype.parseURI.call(service.prototype, url); 44 | } 45 | 46 | constructor(ServiceConfig, AuthServer, serverOpts) { 47 | ServiceConfig = ServiceConfig || {}; 48 | this.ENGINES = FreyrCore.ENGINES.map(Engine => new Engine(ServiceConfig[Engine[symbols.meta].ID], AuthServer, serverOpts)); 49 | } 50 | 51 | identifyService = FreyrCore.identifyService; 52 | 53 | collateSources = FreyrCore.collateSources; 54 | 55 | sortSources = FreyrCore.sortSources; 56 | } 57 | -------------------------------------------------------------------------------- /src/p_flatten.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | 3 | export default async function flattener(array) { 4 | array = await array; 5 | return ( 6 | await Promise.mapSeries(Array.isArray(array) ? array : [], async item => 7 | !Array.isArray(item) ? item : flattener(item.flat(Infinity)), 8 | ) 9 | ).flat(Infinity); 10 | } 11 | -------------------------------------------------------------------------------- /src/parse_range.js: -------------------------------------------------------------------------------- 1 | import esMain from 'es-main'; 2 | 3 | class ParseError extends Error {} 4 | 5 | /** 6 | * Parse ranges in strings. 7 | * Syntax: `[a][..[=][b]]` 8 | * @param {string} spec 9 | * @example (valid) `a`, `a..`, `..b`, `a..b`, `a..=`, `..=b`, `a..=b` 10 | * @example (optional) ``, `..`, `..=` 11 | * @returns {{min: string; max: string; inclusive: boolean; strict: boolean;}} 12 | * - `min`: The minimum part of the range. E.g `5` in `5..10` 13 | * - `max`: The maximum part of the range. E.g `10` in `5..10` 14 | * - `inclusive`: Whether or not the maximum is a part of the range. E.g `true` in `5..=10` 15 | * - `strict`: Whether or not the spec was not a range. E.g `true` in `7` 16 | */ 17 | export default function parseRange(spec) { 18 | let [min, max] = []; 19 | const sepIndex = spec.indexOf('..'); 20 | [min, max] = (~sepIndex ? [spec.slice(0, sepIndex), spec.slice(sepIndex + 2)] : [spec]).map(part => part.trim()); 21 | let inclusive = !!max && max.startsWith('='); 22 | [min, max] = [min, inclusive ? (max ? max.slice(1) : min) : max].map(part => part || undefined); 23 | const strict = !~sepIndex; 24 | if (strict && !max) [max, inclusive] = [min, true]; 25 | return {min, max, inclusive, strict}; 26 | } 27 | 28 | /** 29 | * Parse a number-typed range 30 | * @param {*} spec 31 | * @param {*} strictSyntax Whether or not to throw on invalid parts 32 | * @example (valid) `1`, `1..`, `..5`, `1..5`, `1..=`, `..=5`, `1..=5` 33 | */ 34 | parseRange.num = function parseNumRange(spec, strictSyntax = false) { 35 | let {min, max, inclusive} = parseRange(spec); 36 | [min = -Infinity, max = Infinity, inclusive = inclusive] = [min, max].map(part => part && parseInt(part, 10)); 37 | if (strictSyntax && [min, max].some(Number.isNaN)) throw new ParseError(`Invalid num range spec syntax \`${spec}\``); 38 | return {parsed: {min, max, inclusive}, check: num => num >= min && (inclusive ? num <= max : num < max)}; 39 | }; 40 | 41 | /** 42 | * Parse a duration oriented range 43 | * @param {*} spec 44 | * @param {*} strictSyntax Whether or not to throw on invalid parts 45 | * @example (valid) `1s`, `00:30..`, `..3:40`, `20..1:25`, `1s..=60000ms`, `..=200s`, `2:30..=310000ms` 46 | */ 47 | parseRange.time = function parseTimeRange(spec, strictSyntax = false) { 48 | const cast = val => 49 | val !== undefined 50 | ? val.includes(':') 51 | ? val.split(':').reduce((acc, time) => 60 * acc + +time) * 1000 52 | : val.endsWith('h') 53 | ? parseInt(val.slice(0, -1), 10) * 3600000 54 | : val.endsWith('m') 55 | ? parseInt(val.slice(0, -1), 10) * 60000 56 | : val.endsWith('ms') 57 | ? parseInt(val.slice(0, -2), 10) 58 | : val.endsWith('s') 59 | ? parseInt(val.slice(0, -1), 10) * 1000 60 | : parseInt(val, 10) * 1000 61 | : val; 62 | let {min, max, inclusive} = parseRange(spec); 63 | [min = -Infinity, max = Infinity, inclusive = inclusive] = [min, max].map(cast); 64 | if (strictSyntax && [min, max].some(Number.isNaN)) throw new ParseError(`Invalid time range spec syntax \`${spec}\``); 65 | return {parsed: {min, max, inclusive}, check: time => time >= min && (inclusive ? time <= max : time < max)}; 66 | }; 67 | 68 | function initTest() { 69 | function test_num(spec, values) { 70 | console.log('%j', spec); 71 | const parseBlock = parseRange.num(spec); 72 | console.log(parseBlock.parsed); 73 | values.forEach(value => console.log(`[${value.toString().padStart(2)}] ${parseBlock.check(value)}`)); 74 | } 75 | // jscpd:ignore-start 76 | function test_time(spec, values) { 77 | console.log('%j', spec); 78 | const parseBlock = parseRange.time(spec); 79 | console.log(parseBlock.parsed); 80 | values.forEach(value => console.log(`[${value.toString().padStart(2)}] ${parseBlock.check(value)}`)); 81 | } 82 | // jscpd:ignore-end 83 | 84 | test_num(' ', [1, 2, 3]); 85 | test_num('7 ', [6, 7, 8]); 86 | test_num('.. ', [1, 2, 3]); 87 | test_num('..= ', [4, 5, 6]); 88 | test_num('3.. ', [2, 3, 4]); 89 | test_num('..4 ', [3, 4, 5]); 90 | test_num('..=4 ', [3, 4, 5]); 91 | test_num('5..10', [4, 5, 9, 10, 11]); 92 | test_num('3..=9', [2, 3, 8, 9, 10]); 93 | // invalids 94 | test_num('a..b ', [1, 2, 3]); 95 | test_num('... ', [1, 2, 3]); 96 | test_num('...=9', [8, 9, 10]); 97 | 98 | test_time('3:30..3:35 ', [209999, 210000, 214999, 215000, 215001]); 99 | test_time('3s..9s ', [2999, 3000, 8999, 9000, 9001]); 100 | test_time('10s..=00:30', [9999, 10000, 29999, 30000, 30001]); 101 | test_time('20..50s ', [19999, 20000, 49999, 50000, 50001]); 102 | } 103 | 104 | if (esMain(import.meta)) initTest(); 105 | -------------------------------------------------------------------------------- /src/services/apple_music.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase, no-underscore-dangle, class-methods-use-this */ 2 | import xurl from 'url'; 3 | import path from 'path'; 4 | 5 | import got from 'got'; 6 | import Promise from 'bluebird'; 7 | import NodeCache from 'node-cache'; 8 | import {Client} from '@yujinakayama/apple-music'; 9 | 10 | import symbols from '../symbols.js'; 11 | 12 | const validUriTypes = ['track', 'album', 'artist', 'playlist']; 13 | 14 | export default class AppleMusic { 15 | static [symbols.meta] = { 16 | ID: 'apple_music', 17 | DESC: 'Apple Music', 18 | PROPS: { 19 | isQueryable: true, 20 | isSearchable: false, 21 | isSourceable: false, 22 | }, 23 | // https://www.debuggex.com/r/Pv_Prjinkz1m2FOB 24 | VALID_URL: 25 | /(?:(?:(?:(?:https?:\/\/)?(?:www\.)?)(?:(?:music|(?:geo\.itunes))\.apple.com)\/([a-z]{2})\/(song|album|artist|playlist)\/(?:([^/]+)\/)?\w+)|(?:apple_music:(track|album|artist|playlist):([\w.]+)))/, 26 | PROP_SCHEMA: { 27 | developerToken: {type: 'string'}, 28 | }, 29 | }; 30 | 31 | [symbols.meta] = AppleMusic[symbols.meta]; 32 | 33 | #store = { 34 | cache: new NodeCache(), 35 | core: null, 36 | axiosInstance: null, 37 | expiry: null, 38 | defaultStorefront: null, 39 | isAuthenticated: false, 40 | }; 41 | 42 | constructor(config) { 43 | if (!config) throw new Error(`[AppleMusic] Please define a configuration object`); 44 | if (typeof config !== 'object') throw new Error(`[AppleMusic] Please define a configuration as an object`); 45 | if (config.developerToken) 46 | try { 47 | this.#store.expiry = this.expiresAt(config.developerToken); 48 | } catch (e) { 49 | let err = new Error('Failed to parse token expiration date'); 50 | err.cause = e; 51 | throw err; 52 | } 53 | this.#store.core = new Client({developerToken: config.developerToken}); 54 | this.#store.axiosInstance = this.#store.core.songs.axiosInstance; 55 | for (let instance of [this.#store.core.albums, this.#store.core.artists, this.#store.core.playlists]) 56 | instance.axiosInstance = this.#store.axiosInstance; 57 | this.#store.axiosInstance.defaults.headers['Origin'] = 'https://music.apple.com'; 58 | this.#store.defaultStorefront = config.storefront; 59 | } 60 | 61 | expiresAt(developerToken) { 62 | let segments = developerToken.split('.'); 63 | let payload = Buffer.from(segments[1] || '', 'base64'); 64 | let parsed = JSON.parse(payload.toString()); 65 | return parsed.exp * 1000; 66 | } 67 | 68 | loadConfig(config) { 69 | if (config.developerToken) { 70 | this.#store.expiry = this.expiresAt(config.developerToken); 71 | this.#store.core.configuration.developerToken = config.developerToken; 72 | this.#store.axiosInstance.defaults.headers['Authorization'] = `Bearer ${config.developerToken}`; 73 | } 74 | } 75 | 76 | hasOnceAuthed() { 77 | return this.#store.isAuthenticated; 78 | } 79 | 80 | async isAuthed() { 81 | if (Date.now() < this.#store.expiry) 82 | try { 83 | let test_id = 1626195797; // https://music.apple.com/us/song/united-in-grief/1626195797 84 | let res = await this.#store.core.songs.get(test_id, {storefront: 'us'}); 85 | return res.data?.[0]?.id == test_id; 86 | } catch {} 87 | return false; 88 | } 89 | 90 | newAuth() { 91 | throw Error('Unimplemented: [AppleMusic:newAuth()]'); 92 | } 93 | 94 | canTryLogin() { 95 | return true; 96 | } 97 | 98 | hasProps() { 99 | return true; 100 | } 101 | 102 | getProps() { 103 | return { 104 | developerToken: this.#store.core.configuration.developerToken, 105 | }; 106 | } 107 | 108 | async login() { 109 | let browsePage = await got('https://music.apple.com/us/browse').text(); 110 | let scriptUri; 111 | if (!(scriptUri = browsePage.match(/assets\/index-[a-z0-9]{8}\.js/)?.[0])) 112 | throw new Error('Unable to extract core script from Apple Music'); 113 | let script = await got(`https://music.apple.com/${scriptUri}`).text(); 114 | let developerToken; 115 | if (!(developerToken = script.match(/eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ[^"]+/)?.[0])) 116 | throw new Error('Unable to extract developerToken from Apple Music core script'); 117 | this.#store.expiry = this.expiresAt(developerToken); 118 | this.#store.core.configuration.developerToken = developerToken; 119 | this.#store.axiosInstance.defaults.headers['Authorization'] = `Bearer ${developerToken}`; 120 | return (this.#store.isAuthenticated = true); 121 | } 122 | 123 | validateType(uri) { 124 | const {type} = this.identifyType(uri); 125 | return type in validUriTypes; 126 | } 127 | 128 | identifyType(uri) { 129 | return this.parseURI(uri).type; 130 | } 131 | 132 | parseURI(uri, storefront) { 133 | const match = uri.match(AppleMusic[symbols.meta].VALID_URL); 134 | if (!match) return null; 135 | const isURI = !!match[4]; 136 | const parsedURL = xurl.parse(uri, true); 137 | const collection_type = isURI ? match[4] : match[2] === 'song' ? 'track' : match[2]; 138 | const id = isURI ? match[5] : parsedURL.query.i || path.basename(parsedURL.pathname); 139 | const type = isURI ? match[4] : collection_type == 'album' && parsedURL.query.i ? 'track' : collection_type; 140 | const scope = collection_type == 'track' || (collection_type == 'album' && parsedURL.query.i) ? 'song' : collection_type; 141 | storefront = match[1] || storefront || (#store in this ? this.#store.defaultStorefront : null) || 'us'; 142 | return { 143 | id, 144 | type, 145 | key: match[3] || null, 146 | uri: `apple_music:${type}:${id}`, 147 | url: `https://music.apple.com/${storefront}/${scope}/${id}`, 148 | storefront, 149 | collection_type, 150 | }; 151 | } 152 | 153 | wrapTrackMeta(trackInfo, albumInfo = {}) { 154 | return { 155 | id: trackInfo.id, 156 | uri: `apple_music:track:${trackInfo.id}`, 157 | link: trackInfo.attributes.url, 158 | name: trackInfo.attributes.name, 159 | artists: [trackInfo.attributes.artistName], 160 | album: albumInfo.name, 161 | album_uri: `apple_music:album:${albumInfo.id}`, 162 | album_type: albumInfo.type, 163 | images: trackInfo.attributes.artwork, 164 | duration: trackInfo.attributes.durationInMillis, 165 | album_artist: albumInfo.artists[0], 166 | track_number: trackInfo.attributes.trackNumber, 167 | total_tracks: albumInfo.ntracks, 168 | release_date: albumInfo.release_date, 169 | disc_number: trackInfo.attributes.discNumber, 170 | total_discs: albumInfo.tracks.reduce((acc, track) => Math.max(acc, track.attributes.discNumber), 1), 171 | contentRating: trackInfo.attributes.contentRating, 172 | isrc: trackInfo.attributes.isrc, 173 | genres: trackInfo.attributes.genreNames, 174 | label: albumInfo.label, 175 | copyrights: albumInfo.copyrights, 176 | composers: trackInfo.attributes.composerName, 177 | compilation: albumInfo.type === 'compilation', 178 | getImage: albumInfo.getImage, 179 | }; 180 | } 181 | 182 | wrapAlbumData(albumObject) { 183 | return { 184 | id: albumObject.id, 185 | uri: albumObject.attributes.url, 186 | name: albumObject.attributes.name.replace(/\s-\s(Single|EP)$/, ''), 187 | artists: [albumObject.attributes.artistName], 188 | type: 189 | albumObject.attributes.artistName === 'Various Artists' && albumObject.relationships.artists.data.length === 0 190 | ? 'compilation' 191 | : albumObject.attributes.isSingle 192 | ? 'single' 193 | : 'album', 194 | genres: albumObject.attributes.genreNames, 195 | copyrights: [{type: 'P', text: albumObject.attributes.copyright}], 196 | images: albumObject.attributes.artwork, 197 | label: albumObject.attributes.recordLabel, 198 | release_date: (date => 199 | typeof date === 'string' 200 | ? date 201 | : [ 202 | [date.year, 4], 203 | [date.month, 2], 204 | [date.day, 2], 205 | ] 206 | .map(([val, size]) => val.toString().padStart(size, '0')) 207 | .join('-'))(albumObject.attributes.releaseDate), 208 | tracks: albumObject.tracks, 209 | ntracks: albumObject.attributes.trackCount, 210 | getImage(width, height) { 211 | const min = (val, max) => Math.min(max, val) || max; 212 | const images = albumObject.attributes.artwork; 213 | return images.url.replace('{w}x{h}', `${min(width, images.width)}x${min(height, images.height)}`); 214 | }, 215 | }; 216 | } 217 | 218 | wrapArtistData(artistObject) { 219 | return { 220 | id: artistObject.id, 221 | uri: artistObject.attributes.url, 222 | name: artistObject.attributes.name, 223 | genres: artistObject.attributes.genreNames, 224 | albums: artistObject.albums.map(album => album.id), 225 | nalbums: artistObject.albums.length, 226 | }; 227 | } 228 | 229 | wrapPlaylistData(playlistObject) { 230 | return { 231 | id: playlistObject.id, 232 | uri: playlistObject.attributes.url, 233 | name: playlistObject.attributes.name, 234 | followers: null, 235 | description: (playlistObject.attributes.description || {short: null}).short, 236 | owner_id: null, 237 | owner_name: playlistObject.attributes.curatorName, 238 | type: playlistObject.attributes.playlistType.split('-').map(word => `${word[0].toUpperCase()}${word.slice(1)}`), 239 | tracks: playlistObject.tracks, 240 | ntracks: playlistObject.tracks.length, 241 | // hasNonTrack: !!~playlistObject.attributes.trackTypes.findIndex(type => type !== 'songs'), 242 | }; 243 | } 244 | 245 | async processData(uris, max, store, coreFn) { 246 | const wasArr = Array.isArray(uris); 247 | uris = (wasArr ? uris : [uris]).flatMap(_uri => { 248 | const parsed = this.parseURI(_uri, store); 249 | if (!parsed) return []; 250 | parsed.result = this.#store.cache.get(parsed.uri); 251 | return [[parsed.id, parsed]]; 252 | }); 253 | const packs = uris.filter(([, {result}]) => !result).map(([, parsed]) => parsed); 254 | let results = new Map(); 255 | for (const [id, {result}] of uris) { 256 | results.set(id, result); 257 | } 258 | uris = Object.fromEntries(uris); 259 | if (packs.length) 260 | ( 261 | await Promise.mapSeries( 262 | Object.entries( 263 | // organise by storefront 264 | packs.reduce( 265 | (all, item) => (((all[item.storefront] = all[item.storefront] || []), all[item.storefront].push(item)), all), 266 | {}, 267 | ), 268 | ), 269 | async ([storefront, _items]) => 270 | Promise.mapSeries( 271 | // cut to maximum query length 272 | ((f, c) => ( 273 | (c = Math.min(c, f.length)), [...Array(Math.ceil(f.length / c))].map((_, i) => f.slice(i * c, i * c + c)) 274 | ))(_items, max || Infinity), 275 | async items => coreFn(items, storefront), // request select collection 276 | ), 277 | ) 278 | ) 279 | .flat(2) 280 | .forEach(item => (item ? (this.#store.cache.set(uris[item.id].uri, item), results.set(item.id, item)) : null)); 281 | results = [...results.values()]; 282 | return !wasArr ? results[0] : results; 283 | } 284 | 285 | async depaginate(paginatedObject, nextHandler) { 286 | const {data, next} = await paginatedObject; 287 | if (!next) return data; 288 | return data.concat(await this.depaginate(await nextHandler(next), nextHandler)); 289 | } 290 | 291 | async getTrack(uris, store) { 292 | return this.processData(uris, 300, store, async (items, storefront) => { 293 | const {data: tracks} = await this.#store.core.songs.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront}); 294 | await this.getAlbum( 295 | tracks.flatMap(item => item.relationships.albums.data.map(item => `apple_music:album:${item.id}`)), 296 | storefront, 297 | ); 298 | return Promise.mapSeries(tracks, async track => { 299 | track.artists = await this.depaginate( 300 | track.relationships.artists, 301 | async nextUrl => await this.#store.core.songs.get(`${track.id}${nextUrl.split(track.href)[1]}`, {storefront}), 302 | ); 303 | track.albums = await this.depaginate(track.relationships.albums, nextUrl => { 304 | let err = new Error('Unimplemented: track albums pagination'); 305 | [err.trackId, err.trackHref, err.nextUrl] = [track.id, track.href, nextUrl]; 306 | throw err; 307 | // this.#store.core.songs.get(`${track.id}${nextUrl.split(track.href)[1]}`, {storefront}); 308 | }); 309 | if (track.albums.length > 1) { 310 | let err = new Error('Unimplemented: track with multiple albums'); 311 | [err.trackId, err.trackHref] = [track.id, track.href]; 312 | throw err; 313 | } 314 | return this.wrapTrackMeta( 315 | track, 316 | await this.getAlbum(`apple_music:album:${track.relationships.albums.data[0].id}`, storefront), 317 | ); 318 | }); 319 | }); 320 | } 321 | 322 | async getAlbum(uris, store) { 323 | return this.processData(uris, 100, store, async (items, storefront) => 324 | Promise.mapSeries( 325 | (await this.#store.core.albums.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront})).data, 326 | async album => { 327 | album.tracks = await this.depaginate(album.relationships.tracks, nextUrl => { 328 | let err = new Error('Unimplemented: album tracks pagination'); 329 | [err.albumId, err.albumHref, err.nextUrl] = [album.id, album.href, nextUrl]; 330 | throw err; 331 | // this.#store.core.albums.get(`${album.id}${nextUrl.split(album.href)[1]}`, {storefront}); 332 | }); 333 | return this.wrapAlbumData(album); 334 | }, 335 | ), 336 | ); 337 | } 338 | 339 | async getAlbumTracks(url, store) { 340 | return this.getTrack( 341 | (await this.getAlbum(url, store)).tracks.map(track => track.attributes.url), 342 | store, 343 | ); 344 | } 345 | 346 | async getArtist(uris, store) { 347 | return this.processData(uris, 25, store, async (items, storefront) => 348 | Promise.mapSeries( 349 | (await this.#store.core.artists.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront})).data, 350 | async artist => { 351 | artist.albums = await this.depaginate(artist.relationships.albums, nextUrl => 352 | this.#store.core.artists.get(`${artist.id}${nextUrl.split(artist.href)[1]}`, {storefront}), 353 | ); 354 | return this.wrapArtistData(artist); 355 | }, 356 | ), 357 | ); 358 | } 359 | 360 | async getPlaylist(uris, store) { 361 | return this.processData(uris, 25, store, async (items, storefront) => 362 | Promise.mapSeries( 363 | (await this.#store.core.playlists.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront})).data, 364 | async playlist => { 365 | playlist.tracks = await this.depaginate(playlist.relationships.tracks, nextUrl => 366 | this.#store.core.playlists.get(`${playlist.id}${nextUrl.split(playlist.href)[1]}`, {storefront}), 367 | ); 368 | return this.wrapPlaylistData(playlist); 369 | }, 370 | ), 371 | ); 372 | } 373 | 374 | async getPlaylistTracks(uris, store) { 375 | return this.getTrack( 376 | (await this.getPlaylist(uris, store)).tracks.map(track => track.attributes.url), 377 | store, 378 | ); 379 | } 380 | 381 | async getArtistAlbums(uris, store) { 382 | return this.getAlbum( 383 | (await this.getArtist(uris)).albums.map(album => `apple_music:album:${album}`), 384 | store, 385 | ); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /src/services/deezer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase, no-underscore-dangle, class-methods-use-this, max-classes-per-file */ 2 | import url from 'url'; 3 | import path from 'path'; 4 | 5 | import got from 'got'; 6 | import NodeCache from 'node-cache'; 7 | 8 | import symbols from '../symbols.js'; 9 | import AsyncQueue from '../async_queue.js'; 10 | 11 | const validUriTypes = ['track', 'album', 'artist', 'playlist']; 12 | 13 | class WebapiError extends Error { 14 | constructor(message, statusCode, status) { 15 | super(message); 16 | if (status) this.status = status; 17 | if (statusCode) this.statusCode = statusCode; 18 | } 19 | } 20 | 21 | const sleep = ms => new Promise(res => setTimeout(res, ms)); 22 | 23 | export class DeezerCore { 24 | legacyApiUrl = 'https://api.deezer.com'; 25 | altApiUrl = 'https://www.deezer.com/ajax/gw-light.php'; 26 | 27 | requestObject = got.extend({ 28 | responseType: 'json', 29 | searchParams: {output: 'json'}, 30 | }); 31 | 32 | #validatorData = {expires: 0, queries: []}; 33 | 34 | #retrySymbol = Symbol('DeezerCoreTrialCount'); 35 | 36 | #getIfHasError = response => { 37 | if (!(response.body && typeof response.body === 'object' && 'error' in response.body)) return null; 38 | 39 | if (Array.isArray(response.body.error)) return response.body.error.length > 0 ? response.body.error[0] : null; 40 | 41 | return response.body.error; 42 | }; 43 | 44 | validatorQueue = new AsyncQueue('validatorQueue', 1, async now => { 45 | if (this.#validatorData.queries.length === 50) 46 | await sleep(this.#validatorData.expires - Date.now()).then(() => Promise.all(this.#validatorData.queries)); 47 | if (this.#validatorData.expires <= (now = Date.now())) this.#validatorData = {expires: now + 5000, queries: []}; 48 | return new Promise(res => this.#validatorData.queries.push(new Promise(res_ => res(res_)))); 49 | }); 50 | 51 | #sendRequest = async (ref, opts, retries) => { 52 | retries = typeof retries === 'object' ? retries : {prior: 0, remaining: retries}; 53 | const ticketFree = await this.validatorQueue[retries.prior === 0 ? 'push' : 'unshift'](); 54 | return this.requestObject 55 | .get(ref, { 56 | prefixUrl: this.legacyApiUrl, 57 | searchParams: opts, 58 | }) 59 | .finally(ticketFree) 60 | .then((response, error) => { 61 | if ((error = this.#getIfHasError(response)) && error.code === 4 && error.message === 'Quota limit exceeded') { 62 | error[this.#retrySymbol] = retries.prior + 1; 63 | if (retries.remaining > 1) 64 | return this.#sendRequest(ref, opts, {prior: retries.prior + 1, remaining: retries.remaining - 1}); 65 | } 66 | return response; 67 | }); 68 | }; 69 | 70 | totalTrials = 5; 71 | 72 | async wrappedCall(called) { 73 | const response = await called.catch(err => { 74 | throw new WebapiError( 75 | `${err.syscall ? `${err.syscall} ` : ''}${err.code} ${err.hostname || err.host}`, 76 | err.response ? err.response.statusCode : null, 77 | ); 78 | }); 79 | 80 | let error; 81 | if ((error = this.#getIfHasError(response))) { 82 | const err = new WebapiError(`${error.code} [${error.type}]: ${error.message}`, null, error.code); 83 | if (error[this.#retrySymbol]) err[this.#retrySymbol] = error[this.#retrySymbol]; 84 | throw err; 85 | } 86 | 87 | return response.body; 88 | } 89 | 90 | #altAuth = {token: null, sessionId: null}; 91 | 92 | async altApiCall(method, opts) { 93 | if (!this.#altAuth.token) { 94 | let result = await this._altApiCall('deezer.getUserData'); 95 | this.#altAuth = {token: result.checkForm, sessionId: result.SESSION_ID}; 96 | } 97 | 98 | return this._altApiCall(method, opts); 99 | } 100 | 101 | async _altApiCall(method, opts) { 102 | const response = await this.wrappedCall( 103 | this.requestObject.post(this.altApiUrl, { 104 | headers: {...(this.#altAuth?.sessionId && {cookie: `sid=${this.#altAuth.sessionId}`})}, 105 | searchParams: {method, api_version: '1.0', api_token: this.#altAuth.token ?? ''}, 106 | json: {lang: 'en', ...opts}, 107 | }), 108 | ); 109 | 110 | return response.results; 111 | } 112 | 113 | processID(gnFn) { 114 | return (id, opts) => this.wrappedCall(this.#sendRequest(gnFn(id), opts, this.totalTrials || 5)); 115 | } 116 | 117 | processList(gnFn) { 118 | const wrapPagination = (id, wrpFnx, pagedURL, opts) => 119 | pagedURL 120 | ? () => wrpFnx(id, (({index, limit}) => ({index, limit: limit || opts.limit}))(url.parse(pagedURL, true).query)) 121 | : null; 122 | const decoyProcessor = async (id, opts = {}) => { 123 | const itemObject = await gnFn(id, {index: opts.index || 0, limit: Math.min(opts.limit, 300) || 300}); 124 | itemObject.next = wrapPagination(id, decoyProcessor, itemObject.next, opts); 125 | itemObject.prev = wrapPagination(id, decoyProcessor, itemObject.prev, opts); 126 | return itemObject; 127 | }; 128 | return decoyProcessor; 129 | } 130 | 131 | getTrack = this.processID(id => `track/${id}`); 132 | 133 | getAlbum = this.processID(id => `album/${id}`); 134 | 135 | getArtist = this.processID(id => `artist/${id}`); 136 | 137 | getPlaylist = this.processID(id => `playlist/${id}`); 138 | 139 | getAlbumTracks = this.processList((id, opts) => this.getAlbum(`${id}/tracks`, opts)); 140 | 141 | getArtistAlbums = this.processList((id, opts) => this.getArtist(`${id}/albums`, opts)); 142 | 143 | getPlaylistTracks = this.processList((id, opts) => this.getPlaylist(`${id}/tracks`, opts)); 144 | } 145 | 146 | export default class Deezer { 147 | static [symbols.meta] = { 148 | ID: 'deezer', 149 | DESC: 'Deezer', 150 | PROPS: { 151 | isQueryable: true, 152 | isSearchable: false, 153 | isSourceable: false, 154 | }, 155 | // https://www.debuggex.com/r/IuFIxSZGFJ07tOkR 156 | VALID_URL: 157 | /(?:(?:(?:https?:\/\/)?(?:www\.)?)deezer.com(?:\/([a-z]{2}))?\/(track|album|artist|playlist)\/(\d+))|(?:deezer:(track|album|artist|playlist):(\d+))/, 158 | PROP_SCHEMA: {}, 159 | }; 160 | 161 | [symbols.meta] = Deezer[symbols.meta]; 162 | 163 | #store = { 164 | core: new DeezerCore(), 165 | cache: new NodeCache(), 166 | }; 167 | 168 | constructor(config) { 169 | if (config && 'retries' in config) this.#store.core.totalTrials = config.retries + 1; 170 | } 171 | 172 | loadConfig(_config) {} 173 | 174 | hasOnceAuthed() { 175 | throw Error('Unimplemented: [Deezer:hasOnceAuthed()]'); 176 | } 177 | 178 | async isAuthed() { 179 | return true; 180 | } 181 | 182 | newAuth() { 183 | throw Error('Unimplemented: [Deezer:newAuth()]'); 184 | } 185 | 186 | canTryLogin() { 187 | return true; 188 | } 189 | 190 | hasProps() { 191 | return false; 192 | } 193 | 194 | getProps() { 195 | throw Error('Unimplemented: [Deezer:getProps()]'); 196 | } 197 | 198 | async login() { 199 | throw Error('Unimplemented: [Deezer:login()]'); 200 | } 201 | 202 | validateType(uri) { 203 | const {type} = this.identifyType(uri); 204 | return type in validUriTypes; 205 | } 206 | 207 | identifyType(uri) { 208 | return this.parseURI(uri).type; 209 | } 210 | 211 | parseURI(uri, storefront) { 212 | const match = uri.match(Deezer[symbols.meta].VALID_URL); 213 | if (!match) return null; 214 | const isURI = !!match[4]; 215 | const parsedURL = url.parse(uri, true); 216 | const id = isURI ? match[5] : path.basename(parsedURL.pathname); 217 | storefront = match[1] || storefront || 'en'; 218 | const type = match[isURI ? 4 : 2]; 219 | return {id, type, uri: `deezer:${type}:${id}`, url: `https://www.deezer.com/${storefront}/${type}/${id}`, storefront}; 220 | } 221 | 222 | wrapTrackMeta(trackInfo, albumInfo = {}) { 223 | return { 224 | id: trackInfo.id, 225 | uri: `deezer:track:${trackInfo.id}`, 226 | link: trackInfo.link, 227 | name: trackInfo.title, 228 | artists: [trackInfo.artist.name], 229 | album: albumInfo.name, 230 | album_uri: `deezer:album:${albumInfo.id}`, 231 | album_type: albumInfo.type, 232 | images: albumInfo.images, 233 | duration: trackInfo.duration * 1000, 234 | album_artist: albumInfo.artists[0], 235 | track_number: trackInfo.track_position, 236 | total_tracks: albumInfo.ntracks, 237 | release_date: new Date(trackInfo.release_date), 238 | disc_number: trackInfo.disk_number, 239 | total_discs: albumInfo.tracks.reduce((acc, track) => Math.max(acc, track.altData.DISK_NUMBER), 1), 240 | contentRating: !!trackInfo.explicit_lyrics, 241 | isrc: trackInfo.isrc, 242 | genres: albumInfo.genres, 243 | label: albumInfo.label, 244 | copyrights: albumInfo.copyrights, 245 | composers: trackInfo.contributors.map(composer => composer.name).join(', '), 246 | compilation: albumInfo.type === 'compilation', 247 | getImage: albumInfo.getImage, 248 | }; 249 | } 250 | 251 | wrapAlbumData(albumObject, altAlbumObject) { 252 | const artistObject = albumObject.artist || {}; 253 | let altTracks = Object.fromEntries((altAlbumObject.SONGS?.data || []).map(track => [track.SNG_ID, track])); 254 | return { 255 | id: albumObject.id, 256 | uri: albumObject.link, 257 | name: albumObject.title, 258 | artists: [artistObject.name], 259 | type: 260 | artistObject.name === 'Various Artists' && artistObject.id === 5080 261 | ? 'compilation' 262 | : albumObject.record_type === 'single' 263 | ? 'single' 264 | : 'album', 265 | genres: ((albumObject.genres || {}).data || []).map(genre => genre.name), 266 | copyrights: [{type: 'P', text: altAlbumObject.DATA.PRODUCER_LINE}], 267 | images: [albumObject.cover_small, albumObject.cover_medium, albumObject.cover_big, albumObject.cover_xl], 268 | label: albumObject.label, 269 | release_date: new Date(albumObject.release_date), 270 | ntracks: albumObject.nb_tracks, 271 | tracks: albumObject.tracks.data.map(track => ({...track, altData: altTracks[track.id]})), 272 | getImage(width, height) { 273 | const min = (val, max) => Math.min(max, val) || max; 274 | return this.images 275 | .slice() 276 | .pop() 277 | .replace(/(?<=.+\/)\d+x\d+(?=.+$)/g, `${min(width, 1800)}x${min(height, 1800)}`); 278 | }, 279 | }; 280 | } 281 | 282 | wrapArtistData(artistObject) { 283 | return { 284 | id: artistObject.id, 285 | uri: artistObject.link, 286 | name: artistObject.name, 287 | genres: null, 288 | nalbum: artistObject.nb_album, 289 | followers: artistObject.nb_fan, 290 | }; 291 | } 292 | 293 | wrapPlaylistData(playlistObject) { 294 | return { 295 | id: playlistObject.id, 296 | uri: playlistObject.link, 297 | name: playlistObject.title, 298 | followers: playlistObject.fans, 299 | description: playlistObject.description, 300 | owner_id: playlistObject.creator.id, 301 | owner_name: playlistObject.creator.name, 302 | type: `${playlistObject.public ? 'Public' : 'Private'}${playlistObject.collaborative ? ' (Collaborative)' : ''}`, 303 | ntracks: playlistObject.nb_tracks, 304 | tracks: playlistObject.tracks, 305 | }; 306 | } 307 | 308 | createDataProcessor(coreFn) { 309 | return async uri => { 310 | const parsed = this.parseURI(uri); 311 | if (!this.#store.cache.has(parsed.uri)) this.#store.cache.set(parsed.uri, await coreFn(parsed.id)); 312 | return this.#store.cache.get(parsed.uri); 313 | }; 314 | } 315 | 316 | trackQueue = new AsyncQueue( 317 | 'deezer:trackQueue', 318 | 4, 319 | this.createDataProcessor(async id => { 320 | const track = await this.#store.core.getTrack(id); 321 | return this.wrapTrackMeta(track, await this.getAlbum(`deezer:album:${track.album.id}`)); 322 | }), 323 | ); 324 | 325 | async getTrack(uris) { 326 | return this.trackQueue.push(uris); 327 | } 328 | 329 | albumQueue = new AsyncQueue( 330 | 'deezer:albumQueue', 331 | 4, 332 | this.createDataProcessor(async id => { 333 | let [album, altAlbumData] = await Promise.all([ 334 | this.#store.core.getAlbum(id), 335 | this.#store.core.altApiCall('deezer.pageAlbum', {alb_id: id}), 336 | ]); 337 | return this.wrapAlbumData(album, altAlbumData); 338 | }), 339 | ); 340 | 341 | async getAlbum(uris) { 342 | return this.albumQueue.push(uris); 343 | } 344 | 345 | artistQueue = new AsyncQueue( 346 | 'deezer:artistQueue', 347 | 4, 348 | this.createDataProcessor(async id => this.wrapArtistData(await this.#store.core.getArtist(id))), 349 | ); 350 | 351 | async getArtist(uris) { 352 | return this.artistQueue.push(uris); 353 | } 354 | 355 | playlistQueue = new AsyncQueue( 356 | 'deezer:playlistQueue', 357 | 4, 358 | this.createDataProcessor(async id => this.wrapPlaylistData(await this.#store.core.getPlaylist(id, {limit: 1}))), 359 | ); 360 | 361 | async getPlaylist(uris) { 362 | return this.playlistQueue.push(uris); 363 | } 364 | 365 | async getAlbumTracks(uri) { 366 | const album = await this.getAlbum(uri); 367 | return this.trackQueue.push(album.tracks.map(track => track.link)); 368 | } 369 | 370 | async getArtistAlbums(uris) { 371 | const artist = await this.getArtist(uris); 372 | return this.wrapPagination( 373 | () => this.#store.core.getArtistAlbums(artist.id, {limit: Math.min(artist.nalbum, Math.max(300, artist.nalbum / 4))}), 374 | data => this.albumQueue.push(data.map(album => album.link)), 375 | ); 376 | } 377 | 378 | async getPlaylistTracks(uri) { 379 | const playlist = await this.getPlaylist(uri); 380 | return this.wrapPagination( 381 | () => 382 | this.#store.core.getPlaylistTracks(playlist.id, {limit: Math.min(playlist.ntracks, Math.max(300, playlist.ntracks / 4))}), 383 | data => this.trackQueue.push(data.map(track => track.link)), 384 | ); 385 | } 386 | 387 | async wrapPagination(genFn, processor) { 388 | const collateAllPages = async px => { 389 | const page = await px(); 390 | if (page.next) page.data.push(...(await collateAllPages(page.next))); 391 | return page.data; 392 | }; 393 | const results = await collateAllPages(genFn); 394 | return processor ? processor(results) : results; 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/services/spotify.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle, class-methods-use-this */ 2 | import Promise from 'bluebird'; 3 | import NodeCache from 'node-cache'; 4 | import * as spotifyUri from 'spotify-uri'; 5 | import SpotifyWebApi from '@miraclx/spotify-web-api-node'; 6 | 7 | import symbols from '../symbols.js'; 8 | 9 | const validUriTypes = ['track', 'album', 'artist', 'playlist']; 10 | 11 | export default class Spotify { 12 | static [symbols.meta] = { 13 | ID: 'spotify', 14 | DESC: 'Spotify', 15 | PROPS: { 16 | isQueryable: true, 17 | isSearchable: false, 18 | isSourceable: false, 19 | }, 20 | // https://www.debuggex.com/r/DgqrkwF-9XXceZ1x 21 | VALID_URL: 22 | /(?:(?:(?:https?:\/\/)?(?:www\.)?)(?:(?:(?:open|play|embed)\.)spotify.com)\/(?:artist|track|album|playlist)\/(?:[0-9A-Za-z]{22}))|(?:spotify:(?:artist|track|album|playlist):(?:[0-9A-Za-z]{22}))/, 23 | PROP_SCHEMA: { 24 | expiry: {type: 'integer'}, 25 | accessToken: {type: 'string'}, 26 | refreshToken: {type: 'string'}, 27 | }, 28 | }; 29 | 30 | [symbols.meta] = Spotify[symbols.meta]; 31 | 32 | #store = { 33 | core: null, 34 | AuthServer: null, 35 | serverOpts: null, 36 | cache: new NodeCache(), 37 | expiry: null, 38 | isAuthenticated: false, 39 | }; 40 | 41 | constructor(config, authServer, serverOpts) { 42 | if (!config) throw new Error(`[Spotify] Please define a configuration object`); 43 | if (typeof config !== 'object') throw new Error(`[Spotify] Please define a configuration as an object`); 44 | if (!config.clientId) throw new Error(`[Spotify] Please define [clientId] as a property within the configuration`); 45 | if (!config.clientSecret) throw new Error(`[Spotify] Please define [clientSecret] as a property within the configuration`); 46 | [this.#store.AuthServer, this.#store.serverOpts] = [authServer, serverOpts]; 47 | this.#store.core = new SpotifyWebApi({ 48 | clientId: config.clientId, 49 | clientSecret: config.clientSecret, 50 | refreshToken: config.refreshToken, 51 | }); 52 | } 53 | 54 | loadConfig(config) { 55 | if (config.expiry) this.#store.expiry = config.expiry; 56 | if (config.accessToken) this.#store.core.setAccessToken(config.accessToken); 57 | if (config.refreshToken) this.#store.core.setRefreshToken(config.refreshToken); 58 | } 59 | 60 | hasOnceAuthed() { 61 | return this.#store.isAuthenticated; 62 | } 63 | 64 | accessTokenIsValid() { 65 | return Date.now() < this.#store.expiry; 66 | } 67 | 68 | async isAuthed() { 69 | return this.accessTokenIsValid(); 70 | } 71 | 72 | newAuth() { 73 | const server = new this.#store.AuthServer({...this.#store.serverOpts, serviceName: 'Spotify'}); 74 | this.#store.core.setRedirectURI(server.getRedirectURL()); 75 | const scope = ['user-read-private', 'user-read-email']; 76 | const authCode = Promise.resolve(server.getCode()); 77 | return { 78 | getUrl: server.init(state => this.#store.core.createAuthorizeURL(scope, state)), 79 | userToAuth: async () => { 80 | const code = await authCode; 81 | const data = await this.#store.core.authorizationCodeGrant(code); 82 | this.setExpiry(data.body.expires_in); 83 | this.#store.core.setRefreshToken(data.body.refresh_token); 84 | this.#store.core.setAccessToken(data.body.access_token); 85 | this.#store.isAuthenticated = true; 86 | return {refreshToken: data.body.refresh_token, expiry: this.#store.expiry}; 87 | }, 88 | }; 89 | } 90 | 91 | setExpiry(expiry) { 92 | this.#store.expiry = Date.now() + expiry * 1000; 93 | } 94 | 95 | canTryLogin() { 96 | return !!this.#store.core.getRefreshToken(); 97 | } 98 | 99 | hasProps() { 100 | return this.#store.isAuthenticated; 101 | } 102 | 103 | getProps() { 104 | return { 105 | expiry: this.#store.expiry, 106 | accessToken: this.#store.core.getAccessToken(), 107 | refreshToken: this.#store.core.getRefreshToken(), 108 | }; 109 | } 110 | 111 | async login(config) { 112 | if (config) this.loadConfig(config); 113 | if (!this.accessTokenIsValid()) { 114 | const data = await this.#store.core.refreshAccessToken(); 115 | this.#store.core.setAccessToken(data.body.access_token); 116 | this.setExpiry(data.body.expires_in); 117 | } 118 | return (this.#store.isAuthenticated = true); 119 | } 120 | 121 | validateType(uri) { 122 | const {type} = spotifyUri.parse(uri); 123 | if (!['local', ...validUriTypes].includes(type)) throw new Error(`Spotify URI type [${type}] is invalid.`); 124 | return uri; 125 | } 126 | 127 | identifyType(uri) { 128 | return this.parseURI(uri).type; 129 | } 130 | 131 | parseURI(uri) { 132 | const parsed = spotifyUri.parse(this.validateType(uri)); 133 | parsed.url = spotifyUri.formatOpenURL(parsed); 134 | parsed.uri = spotifyUri.formatURI(parsed); 135 | return parsed; 136 | } 137 | 138 | wrapTrackMeta(trackInfo, albumInfo = trackInfo.album) { 139 | return trackInfo 140 | ? { 141 | id: trackInfo.id, 142 | uri: trackInfo.uri, 143 | link: trackInfo.external_urls.spotify, 144 | name: trackInfo.name, 145 | artists: trackInfo.artists.map(artist => artist.name), 146 | album: albumInfo.name, 147 | album_uri: albumInfo.uri, 148 | album_type: albumInfo.type, 149 | images: albumInfo.images, 150 | duration: trackInfo.duration_ms, 151 | album_artist: albumInfo.artists[0], 152 | track_number: trackInfo.track_number, 153 | total_tracks: albumInfo.ntracks, 154 | release_date: albumInfo.release_date, 155 | disc_number: trackInfo.disc_number, 156 | total_discs: albumInfo.tracks.reduce((acc, track) => Math.max(acc, track.disc_number), 1), 157 | contentRating: trackInfo.explicit === true ? 'explicit' : 'inoffensive', 158 | isrc: (trackInfo.external_ids || {}).isrc, 159 | genres: albumInfo.genres, 160 | label: albumInfo.label, 161 | copyrights: albumInfo.copyrights, 162 | composers: null, 163 | compilation: albumInfo.type === 'compilation', 164 | getImage: albumInfo.getImage, 165 | } 166 | : null; 167 | } 168 | 169 | wrapAlbumData(albumObject) { 170 | return albumObject 171 | ? { 172 | id: albumObject.id, 173 | uri: albumObject.uri, 174 | name: albumObject.name, 175 | artists: albumObject.artists.map(artist => artist.name), 176 | type: albumObject.artists[0].id === '0LyfQWJT6nXafLPZqxe9Of' ? 'compilation' : albumObject.album_type, 177 | genres: albumObject.genres, 178 | copyrights: albumObject.copyrights, 179 | images: albumObject.images, 180 | label: albumObject.label, 181 | release_date: new Date(albumObject.release_date), 182 | ntracks: albumObject.total_tracks, 183 | tracks: albumObject.tracks.items, 184 | getImage(width, height) { 185 | const {images} = albumObject; 186 | return images 187 | .sort((a, b) => (a.width > b.width && a.height > b.height ? 1 : -1)) 188 | .find((image, index) => index === images.length - 1 || (image.height >= height && image.width >= width)).url; 189 | }, 190 | } 191 | : null; 192 | } 193 | 194 | wrapArtistData(artistObject) { 195 | return artistObject 196 | ? { 197 | id: artistObject.id, 198 | uri: artistObject.uri, 199 | name: artistObject.name, 200 | genres: artistObject.genres, 201 | nalbum: null, 202 | followers: artistObject.followers.total, 203 | } 204 | : null; 205 | } 206 | 207 | wrapPlaylistData(playlistObject) { 208 | return playlistObject 209 | ? { 210 | id: playlistObject.id, 211 | uri: playlistObject.uri, 212 | name: playlistObject.name, 213 | followers: playlistObject.followers.total, 214 | description: playlistObject.description, 215 | owner_id: playlistObject.owner.id, 216 | owner_name: playlistObject.owner.display_name, 217 | type: `${playlistObject.public ? 'Public' : 'Private'}${playlistObject.collaborative ? ' (Collaborative)' : ''}`, 218 | ntracks: playlistObject.tracks.total, 219 | tracks: playlistObject.tracks.items.map(item => item.track), 220 | } 221 | : null; 222 | } 223 | 224 | async processData(uris, max, coreFn) { 225 | const wasArr = Array.isArray(uris); 226 | uris = (wasArr ? uris : [uris]).map(uri => { 227 | const parsedURI = this.parseURI(uri); 228 | uri = spotifyUri.formatURI(parsedURI); 229 | if (parsedURI.type === 'local') return [, {[symbols.errorStack]: {code: 1, uri}}]; 230 | return [parsedURI.id, this.#store.cache.get(uri)]; 231 | }); 232 | const ids = uris.filter(([, value]) => !value).map(([id]) => id); 233 | let results = new Map(); 234 | for (const [id, result] of uris) { 235 | results.set(id, result); 236 | } 237 | uris = Object.fromEntries(uris); 238 | if (ids.length) 239 | ( 240 | await Promise.mapSeries( 241 | ((f, c) => ((c = Math.min(c, f.length)), [...Array(Math.ceil(f.length / c))].map((_, i) => f.slice(i * c, i * c + c))))( 242 | ids, 243 | max || Infinity, 244 | ), 245 | coreFn, 246 | ) 247 | ) 248 | .flat(1) 249 | .forEach(item => (!item ? null : (this.#store.cache.set(item.uri, item), results.set(item.id, item)))); 250 | results = [...results.values()]; 251 | return !wasArr ? results[0] : results; 252 | } 253 | 254 | async getTrack(uris, country) { 255 | return this.processData(uris, 50, async ids => { 256 | const tracks = (await this.#store.core.getTracks(ids, {market: country})).body.tracks.filter(Boolean); 257 | await this.getAlbum( 258 | tracks.map(track => track.album.uri), 259 | country, 260 | ); 261 | return Promise.mapSeries(tracks, async track => this.wrapTrackMeta(track, await this.getAlbum(track.album.uri, country))); 262 | }); 263 | } 264 | 265 | async getAlbum(uris, country) { 266 | return this.processData(uris, 20, async ids => 267 | Promise.mapSeries((await this.#store.core.getAlbums(ids, {market: country})).body.albums, async album => 268 | this.wrapAlbumData(album), 269 | ), 270 | ); 271 | } 272 | 273 | async getAlbumTracks(uri, country) { 274 | return this.getTrack((await this.getAlbum(uri, country)).tracks.map(item => item.uri)); 275 | } 276 | 277 | async getArtist(uris) { 278 | return this.processData(uris, 50, async ids => 279 | Promise.mapSeries((await this.#store.core.getArtists(ids)).body.artists, async artist => this.wrapArtistData(artist)), 280 | ); 281 | } 282 | 283 | async getPlaylist(uri, country) { 284 | const parsedURI = this.parseURI(uri); 285 | uri = spotifyUri.formatURI(parsedURI); 286 | if (!this.#store.cache.has(uri)) 287 | this.#store.cache.set( 288 | uri, 289 | this.wrapPlaylistData((await this.#store.core.getPlaylist(parsedURI.id, {market: country})).body), 290 | ); 291 | return this.#store.cache.get(uri); 292 | } 293 | 294 | async getPlaylistTracks(uri, country) { 295 | const {id} = this.parseURI(uri); 296 | return this.getTrack( 297 | ( 298 | await this._gatherCompletely( 299 | (offset, limit) => this.#store.core.getPlaylistTracks(id, {offset, limit, market: country}), 300 | {offset: 0, limit: 50, sel: 'items'}, 301 | ) 302 | ) 303 | .filter(item => !!(item.track && item.track.name)) 304 | .map(item => item.track.uri), 305 | country, 306 | ); 307 | } 308 | 309 | async getArtistAlbums(uri, country) { 310 | const {id} = this.parseURI(uri); 311 | uri = `spotify:artist_albums:${id}`; 312 | if (!this.#store.cache.has(uri)) 313 | this.#store.cache.set( 314 | uri, 315 | await this.getAlbum( 316 | ( 317 | await this._gatherCompletely( 318 | (offset, limit) => 319 | this.#store.core.getArtistAlbums(id, {offset, limit, country, include_groups: 'album,single,compilation'}), 320 | {offset: 0, limit: 50, sel: 'items'}, 321 | ) 322 | ) 323 | .filter(item => item.name) 324 | .map(album => album.uri), 325 | country, 326 | ), 327 | ); 328 | return this.#store.cache.get(uri); 329 | } 330 | 331 | async checkIsActivelyListening() { 332 | return (await this.#store.core.getMyCurrentPlaybackState()).statusCode !== '204'; 333 | } 334 | 335 | async getActiveTrack() { 336 | return this.#store.core.getMyCurrentPlayingTrack(); 337 | } 338 | 339 | async _gatherCompletely(fn, {offset, limit, sel} = {}) { 340 | const {body} = await fn(offset, limit); 341 | if (body.next) body[sel].push(...(await this._gatherCompletely(fn, {offset: offset + body.limit, limit, sel}))); 342 | return body[sel]; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/services/youtube.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file, no-underscore-dangle */ 2 | import util from 'util'; 3 | 4 | import got from 'got'; 5 | import Promise from 'bluebird'; 6 | import ytSearch from 'yt-search'; 7 | import youtubedl from 'youtube-dl-exec'; 8 | 9 | import walk from '../walkr.js'; 10 | import symbols from '../symbols.js'; 11 | import textUtils from '../text_utils.js'; 12 | import AsyncQueue from '../async_queue.js'; 13 | 14 | class YouTubeSearchError extends Error { 15 | constructor(message, statusCode, status, body) { 16 | super(message); 17 | if (status) this.status = status; 18 | if (statusCode) this.statusCode = statusCode; 19 | if (body) this.body = body; 20 | } 21 | } 22 | 23 | function _getSearchArgs(artists, track, album, duration) { 24 | if (typeof track === 'number') [track, duration] = [, track]; 25 | if (typeof album === 'number') [album, duration] = [, album]; 26 | if (!Array.isArray(artists)) 27 | if (track && artists) artists = [artists]; 28 | else [artists, track] = [[], artists || track]; 29 | if (typeof track !== 'string') throw new Error(' must be a valid string'); 30 | if (typeof album !== 'string') throw new Error(' must be a valid string'); 31 | if (artists.some(artist => typeof artist !== 'string')) 32 | throw new Error(', if defined must be a valid array of strings'); 33 | if (duration && typeof duration !== 'number') throw new Error(', if defined must be a valid number'); 34 | return [artists, track, album, duration]; 35 | } 36 | 37 | /** 38 | * @typedef {( 39 | * { 40 | * title: string, 41 | * type: "Song" | "Video", 42 | * artists: string, 43 | * album: string, 44 | * duration: string, 45 | * duration_ms: number, 46 | * videoId: string, 47 | * playlistId: string, 48 | * accuracy: number, 49 | * getFeeds: () => Promise, 50 | * }[] 51 | * )} YouTubeSearchResult 52 | */ 53 | 54 | function genAsyncGetFeedsFn(url) { 55 | return () => 56 | youtubedl(null, { 57 | '--': [url], 58 | socketTimeout: 20, 59 | cacheDir: false, 60 | dumpSingleJson: true, 61 | noWarnings: true, 62 | }); 63 | } 64 | 65 | export class YouTubeMusic { 66 | static [symbols.meta] = { 67 | ID: 'yt_music', 68 | DESC: 'YouTube Music', 69 | PROPS: { 70 | isQueryable: false, 71 | isSearchable: true, 72 | isSourceable: true, 73 | }, 74 | BITRATES: [96, 128, 160, 192, 256, 320], 75 | }; 76 | 77 | [symbols.meta] = YouTubeMusic[symbols.meta]; 78 | 79 | #store = { 80 | gotInstance: got.extend({ 81 | headers: { 82 | 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36', 83 | }, 84 | }), 85 | apiConfig: null, 86 | }; 87 | 88 | #request = async function request(url, opts) { 89 | const response = await this.#store 90 | .gotInstance(url, opts) 91 | .catch(err => 92 | Promise.reject( 93 | new YouTubeSearchError( 94 | err.message, 95 | err.response && err.response.statusCode, 96 | err.code, 97 | err.response && err.response.body, 98 | ), 99 | ), 100 | ); 101 | if (response.req.res.url === 'https://music.youtube.com/coming-soon/') 102 | throw new YouTubeSearchError('YouTube Music is not available in your country'); 103 | return response.body; 104 | }; 105 | 106 | #deriveConfig = async function deriveConfig(force = false) { 107 | if (this.#store.apiConfig && !force) return this.#store.apiConfig; 108 | const body = await this.#request('https://music.youtube.com/', {method: 'get'}); 109 | let match; 110 | if ((match = (body || '').match(/ytcfg\.set\s*\(\s*({.+})\s*\)\s*;/))) { 111 | this.#store.apiConfig = JSON.parse(match[1]); 112 | return this.#store.apiConfig; 113 | } 114 | throw new YouTubeSearchError('Failed to extract YouTube Music Configuration'); 115 | }; 116 | 117 | #YTM_PATHS = { 118 | PLAY_BUTTON: ['overlay', 'musicItemThumbnailOverlayRenderer', 'content', 'musicPlayButtonRenderer'], 119 | NAVIGATION_BROWSE_ID: ['navigationEndpoint', 'browseEndpoint', 'browseId'], 120 | NAVIGATION_VIDEO_ID: ['navigationEndpoint', 'watchEndpoint', 'videoId'], 121 | NAVIGATION_PLAYLIST_ID: ['navigationEndpoint', 'watchEndpoint', 'playlistId'], 122 | SECTION_LIST: ['sectionListRenderer', 'contents'], 123 | TITLE_TEXT: ['title', 'runs', 0, 'text'], 124 | }; 125 | 126 | #search = async function search(queryObject, params) { 127 | /** 128 | * VideoID Types? 129 | * OMV: Official Music Video 130 | * ATV: 131 | * UGC: User-generated content 132 | */ 133 | if (typeof queryObject !== 'object') throw new Error(' must be an object'); 134 | if (params && typeof params !== 'object') throw new Error(', if defined must be an object'); 135 | 136 | let {INNERTUBE_API_KEY, INNERTUBE_CLIENT_NAME, INNERTUBE_CLIENT_VERSION} = await this.#deriveConfig(); 137 | 138 | const response = await this.#request('https://music.youtube.com/youtubei/v1/search', { 139 | timeout: {request: 10000}, 140 | method: 'post', 141 | searchParams: {alt: 'json', key: INNERTUBE_API_KEY, ...params}, 142 | responseType: 'json', 143 | json: { 144 | context: { 145 | client: { 146 | clientName: INNERTUBE_CLIENT_NAME, 147 | clientVersion: INNERTUBE_CLIENT_VERSION, 148 | hl: 'en', 149 | gl: 'US', 150 | }, 151 | }, 152 | ...queryObject, 153 | }, 154 | headers: { 155 | referer: 'https://music.youtube.com/search', 156 | }, 157 | }); 158 | 159 | const YTM_PATHS = this.#YTM_PATHS; 160 | 161 | const shelf = !('continuationContents' in response) 162 | ? walk(response, YTM_PATHS.SECTION_LIST).map(section => section.musicShelfRenderer || section) 163 | : [ 164 | walk(response, 'continuationContents', 'musicShelfContinuation') || 165 | walk(response, 'continuationContents', 'sectionListContinuation'), 166 | ]; 167 | 168 | return Object.fromEntries( 169 | shelf.map(layer => { 170 | const layerName = walk(layer, YTM_PATHS.TITLE_TEXT); 171 | return [ 172 | layerName === 'Top result' 173 | ? 'top' 174 | : layerName === 'Songs' 175 | ? 'songs' 176 | : layerName === 'Videos' 177 | ? 'videos' 178 | : layerName === 'Albums' 179 | ? 'albums' 180 | : layerName === 'Artists' 181 | ? 'artists' 182 | : layerName === 'Playlists' 183 | ? 'playlists' 184 | : `other${layerName ? `(${layerName})` : ''}`, 185 | { 186 | contents: (layer.contents || []).map(content => { 187 | content = content.musicResponsiveListItemRenderer; 188 | 189 | function getItemRuns(item, index) { 190 | return walk(item, 'flexColumns', index, 'musicResponsiveListItemFlexColumnRenderer', 'text', 'runs'); 191 | } 192 | 193 | function getItemText(item, index, run_index = 0) { 194 | return getItemRuns(item, index)[run_index].text; 195 | } 196 | 197 | const result = {}; 198 | 199 | let type = layerName === 'Songs' ? 'song' : getItemText(content, 1).toLowerCase(); 200 | if (type === 'single') type = 'album'; 201 | 202 | if (['song', 'video', 'album', 'artist', 'playlist'].includes(type)) result.type = type; 203 | 204 | const runs = getItemRuns(content, 1).filter(item => item.text !== ' • '); 205 | const navigable = runs 206 | .filter(item => 'navigationEndpoint' in item) 207 | .map(item => ({name: item.text, id: walk(item, YTM_PATHS.NAVIGATION_BROWSE_ID)})); 208 | 209 | if (['song', 'video', 'album', 'playlist'].includes(type)) { 210 | result.title = getItemText(content, 0); 211 | } 212 | 213 | if (['song', 'video', 'album', 'playlist'].includes(type)) { 214 | [result.artists, result.album] = navigable.reduce( 215 | ([artists, album], item) => { 216 | if (item.id.startsWith('UC')) artists.push(item); 217 | else album = item; 218 | return [artists, album]; 219 | }, 220 | [[], null], 221 | ); 222 | } 223 | 224 | if (['song', 'video'].includes(type)) 225 | result.videoId = walk(content, YTM_PATHS.PLAY_BUTTON, 'playNavigationEndpoint', 'watchEndpoint', 'videoId'); 226 | 227 | if ( 228 | ['artist', 'album', 'playlist'].includes(type) && 229 | !(result.browseId = walk(content, YTM_PATHS.NAVIGATION_BROWSE_ID)) 230 | ) { 231 | return {}; 232 | } 233 | 234 | if (type === 'song') { 235 | result.duration = runs[runs.length - 1].text; 236 | } else if (type === 'video') { 237 | delete result.album; 238 | [result.views, result.duration] = runs.slice(-2).map(item => item.text); 239 | [result.views] = result.views.split(' '); 240 | } else if (type === 'album') { 241 | result.type = runs[0].text.toLowerCase(); 242 | delete result.album; 243 | result.title = getItemText(content, 0); 244 | result.year = runs[runs.length - 1].text; 245 | } else if (type === 'artist') { 246 | result.artist = getItemText(content, 0); 247 | [result.subscribers] = runs[runs.length - 1].text.split(' '); 248 | } else if (type === 'playlist') { 249 | result.author = result.artists; 250 | delete result.artists; 251 | delete result.album; 252 | result.itemCount = parseInt(runs[runs.length - 1].text.split(' ')[0], 10); 253 | } 254 | 255 | return result; 256 | }), 257 | ...(layerName === 'Top result' 258 | ? null 259 | : { 260 | loadMore: !layer.continuations 261 | ? undefined 262 | : async () => { 263 | const continuationObject = layer.continuations[0].nextContinuationData; 264 | return ( 265 | await this.#search( 266 | {}, 267 | { 268 | icit: continuationObject.clickTrackingParams, 269 | continuation: continuationObject.continuation, 270 | }, 271 | ) 272 | ).other; 273 | }, 274 | expand: !layer.bottomEndpoint 275 | ? undefined 276 | : async () => (await this.#search(layer.bottomEndpoint.searchEndpoint, {})).other, 277 | }), 278 | }, 279 | ]; 280 | }), 281 | ); 282 | }; 283 | 284 | /** 285 | * Search the YouTube Music service for matches 286 | * @param {string|string[]} [artists] An artist or list of artists 287 | * @param {string} [track] Track name 288 | * @param {string} [album] Album name 289 | * @param {number} [duration] Duration in milliseconds 290 | * 291 | * If `track` is a number, it becomes duration, leaving `track` undefined. 292 | * If `album` is a number, it becomes duration, leaving `album` undefined. 293 | * If `artists` is a string and `track` is undefined, it becomes `track`, leaving artists empty. 294 | * If `artists` is non-array but `track` is defined, artists becomes an item in the artists array. 295 | * 296 | * @returns {YouTubeSearchResult} YouTubeMusicSearchResults 297 | */ 298 | async search(artists, track, album, duration) { 299 | [artists, track, album, duration] = _getSearchArgs(artists, track, album, duration); 300 | 301 | const results = await this.#search({query: [track, album, ...artists].join(' ')}); 302 | const strippedMeta = textUtils.stripText([...track.split(' '), album, ...artists]); 303 | const validSections = [ 304 | ...((results.top || {}).contents || []), // top recommended songs 305 | ...((results.songs || {}).contents || []), // song section 306 | ...((results.videos || {}).contents || []), // videos section 307 | ] 308 | .map( 309 | item => 310 | item && 311 | 'title' in item && 312 | ['song', 'video'].includes(item.type) && { 313 | ...item, 314 | weight: textUtils.getWeight( 315 | strippedMeta, 316 | textUtils.stripText([ 317 | ...item.title.split(' '), 318 | ...(item.album?.name.split(' ') ?? []), 319 | ...item.artists.map(artist => artist.name), 320 | ]), 321 | ), 322 | }, 323 | ) 324 | .filter(Boolean); 325 | function calculateAccuracyFor(item, weight) { 326 | let accuracy = 0; 327 | // get weighted delta from expected duration 328 | accuracy += weight - (duration ? Math.abs(duration - item.duration_ms) / duration : 0.5) * 100; 329 | // if item is a song, bump remaining by 50%, if video, bump up by 25%, anything else - by 5% 330 | accuracy += (cur => ((item.type === 'song' ? 50 : item.type === 'video' ? 25 : 5) / 100) * cur)(100 - accuracy); 331 | // TODO: CALCULATE ACCURACY BY AUTHOR 332 | return accuracy; 333 | } 334 | const classified = Object.values( 335 | validSections.reduce((final, item) => { 336 | // prune duplicates 337 | if (item.weight > 65 && item && 'videoId' in item && !(item.videoId in final)) { 338 | let cleanItem = { 339 | title: item.title, 340 | type: item.type, 341 | author: item.artists, 342 | duration: item.duration, 343 | duration_ms: item.duration.split(':').reduce((acc, time) => 60 * acc + +time) * 1000, 344 | videoId: item.videoId, 345 | getFeeds: genAsyncGetFeedsFn(item.videoId), 346 | }; 347 | if ((cleanItem.accuracy = calculateAccuracyFor(cleanItem, item.weight)) > 80) final[item.videoId] = cleanItem; 348 | } 349 | return final; 350 | }, {}), 351 | // sort descending by accuracy 352 | ).sort((a, b) => (a.accuracy > b.accuracy ? -1 : 1)); 353 | return classified; 354 | } 355 | } 356 | 357 | export class YouTube { 358 | static [symbols.meta] = { 359 | ID: 'youtube', 360 | DESC: 'YouTube', 361 | PROPS: { 362 | isQueryable: false, 363 | isSearchable: true, 364 | isSourceable: true, 365 | }, 366 | BITRATES: [96, 128, 160, 192, 256, 320], 367 | }; 368 | 369 | [symbols.meta] = YouTube[symbols.meta]; 370 | 371 | #store = { 372 | search: util.promisify(ytSearch), 373 | searchQueue: new AsyncQueue('YouTube:netSearchQueue', 4, async (strippedMeta, ...xFilters) => 374 | ( 375 | await this.#store.search({ 376 | query: [...strippedMeta, ...xFilters].join(' '), 377 | pageStart: 1, 378 | pageEnd: 2, 379 | }) 380 | ).videos.reduce( 381 | (final, item) => ({ 382 | ...final, 383 | ...(textUtils.getWeight(strippedMeta, textUtils.stripText([...item.title.split(' '), item.author.name])) > 70 384 | ? (final.results.push(item), 385 | { 386 | highestViews: Math.max(final.highestViews, (item.views = item.views || 0)), 387 | }) 388 | : {}), 389 | }), 390 | {xFilters, highestViews: 0, results: []}, 391 | ), 392 | ), 393 | }; 394 | 395 | /** 396 | * Search YouTube service for matches 397 | * @param {string|string[]} [artists] An artist or list of artists 398 | * @param {string} [track] Track name 399 | * @param {number} [duration] Duration in milliseconds 400 | * 401 | * If `track` is a number, it becomes duration, leaving `track` undefined. 402 | * If `album` is a number, it becomes duration, leaving `album` undefined. 403 | * If `artists` is a string and `track` is undefined, it becomes `track`, leaving artists empty. 404 | * If `artists` is non-array but `track` is defined, artists becomes an item in the artists array. 405 | * 406 | * @returns {YouTubeSearchResult} YouTubeSearchResults 407 | */ 408 | async search(artists, track, album, duration) { 409 | [artists, track, album, duration] = _getSearchArgs(artists, track, album, duration); 410 | 411 | const strippedArtists = textUtils.stripText(artists); 412 | const strippedMeta = [...textUtils.stripText(track.split(' ')), ...strippedArtists]; 413 | let searchResults = await Promise.all( 414 | ( 415 | await this.#store.searchQueue.push([ 416 | [strippedMeta, ['Official Audio']], 417 | [strippedMeta, ['Audio']], 418 | [strippedMeta, ['Lyrics']], 419 | [strippedMeta, []], 420 | ]) 421 | ).map(result => Promise.resolve(result).reflect()), 422 | ); 423 | if (searchResults.every(result => result.isRejected())) { 424 | const err = searchResults[searchResults.length - 1].reason(); 425 | throw new YouTubeSearchError(err.message, null, err.code); 426 | } 427 | searchResults = searchResults.map(ret => (ret.isFulfilled() ? ret.value() : {})); 428 | const highestViews = Math.max(...searchResults.map(sources => sources.highestViews)); 429 | function calculateAccuracyFor(item) { 430 | let accuracy = 0; 431 | // get weighted delta from expected duration 432 | accuracy += 100 - (duration ? Math.abs(duration - item.duration.seconds * 1000) / duration : 0.5) * 100; 433 | // bump accuracy by max of 80% on the basis of highest views 434 | accuracy += (cur => cur * (80 / 100) * (item.views / highestViews))(100 - accuracy); 435 | // bump accuracy by 60% if video author matches track author 436 | accuracy += (cur => 437 | textUtils.getWeight(strippedArtists, textUtils.stripText([item.author.name])) >= 80 ? (60 / 100) * cur : 0)( 438 | 100 - accuracy, 439 | ); 440 | return accuracy; 441 | } 442 | const final = {}; 443 | searchResults.forEach(source => { 444 | if (Array.isArray(source.results)) 445 | source.results.forEach(item => { 446 | // prune duplicates 447 | if (item && 'videoId' in item && !(item.videoId in final)) 448 | final[item.videoId] = { 449 | title: item.title, 450 | type: item.type, 451 | author: item.author.name, 452 | duration: item.duration.timestamp, 453 | duration_ms: item.duration.seconds * 1000, 454 | videoId: item.videoId, 455 | xFilters: source.xFilters, 456 | accuracy: calculateAccuracyFor(item), 457 | getFeeds: genAsyncGetFeedsFn(item.videoId), 458 | }; 459 | }); 460 | }); 461 | return Object.values(final).sort((a, b) => (a.accuracy > b.accuracy ? -1 : 1)); 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /src/stack_logger.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import util from 'util'; 3 | 4 | export default class StackLogger { 5 | #store = { 6 | indent: 0, // indentation for this instance 7 | indentSize: 0, // indentation for next instance from ours 8 | indentor: ' ', // indentation character 9 | autoTick: false, // whether or not to auto tick printers 10 | }; 11 | 12 | /** 13 | * Create stacking loggers by means of indentation 14 | * @param {{}} [opts] Options 15 | * @param {number} [opts.indent] Indentation from 0 for this instance. 16 | * @param {any} [opts.indentor] Indentation fill for indentation range. 17 | * @param {number} [opts.indentSize] Size for subsequent instances created from self. 18 | * @param {boolean} [opts.autoTick] Whether or not to auto tick printers. 19 | */ 20 | constructor(opts) { 21 | opts = opts || {}; 22 | this.#store.indent = opts.indent && typeof opts.indent === 'number' ? opts.indent : 0; 23 | this.#store.indentor = opts.indentor || ' '; 24 | this.#store.indentSize = opts.indentSize && typeof opts.indentSize === 'number' ? opts.indentSize : 2; 25 | this.#store.autoTick = typeof opts.autoTick === 'boolean' ? opts.autoTick : true; 26 | } 27 | 28 | /** 29 | * Get/Set the current instance's indentation 30 | * @param {number} [value] New indentation 31 | */ 32 | indentation(val) { 33 | if (val && typeof val === 'number') this.#store.indent = val; 34 | return this.#store.indent; 35 | } 36 | 37 | /** 38 | * Get/Set the current instance's indentation 39 | * @param {any} [indentor] The new indentor 40 | */ 41 | indentor(indentor) { 42 | if (indentor) this.#store.indentor = indentor; 43 | return this.#store.indentor; 44 | } 45 | 46 | /** 47 | * Get/Set the currently held indentSize 48 | * @param {number} [size] New indentSize 49 | */ 50 | indentSize(size) { 51 | if (size && typeof size === 'number') this.#store.indentSize = size; 52 | return this.#store.indentSize; 53 | } 54 | 55 | /** 56 | * Opts to extend self with 57 | * @param {{}} [opts] Options 58 | * @param {number} [opts.indent] Indentation from 0 for this instance. 59 | * @param {any} [opts.indentor] Indentation fill for indentation range. 60 | * @param {number} [opts.indentSize] Size for subsequent instances created from self. 61 | */ 62 | extend(opts) { 63 | return new StackLogger({...this.#store, ...(typeof opts === 'object' ? opts : {})}); 64 | } 65 | 66 | /** 67 | * Create a logger instance whose indentation is extended from the former 68 | * If `indent` is omitted, it will autoTick with `opts.indentSize` 69 | * If `indentSize` is omitted, it will not increment and use `this` 70 | * @param {number} [indent] Size to add to self's `indentation` 71 | * @param {number} [indentSize] Size to add to self's `indentSize` 72 | */ 73 | tick(indent, indentSize) { 74 | return this.extend({ 75 | indent: this.#store.indent + (typeof indent === 'number' ? indent : this.#store.indentSize), 76 | indentSize: this.#store.indentSize + (typeof indentSize === 'number' ? indent : 0), 77 | }); 78 | } 79 | 80 | /** 81 | * Write raw text to stdout 82 | * * Adds no indentation and no EOL 83 | * * Returns self without extending indentation 84 | * @param {...any} msgs Messages to write out 85 | */ 86 | write(...msgs) { 87 | process.stdout.write(this.getText(0, msgs)); 88 | return this; 89 | } 90 | 91 | /** 92 | * Write indented text to stdout 93 | * * Adds indentation but no EOL 94 | * * Returns a stacklogger with extended indentation if `opts.autoTick` else `this` 95 | * @param {...any} msgs Messages to write out 96 | */ 97 | print(...msgs) { 98 | process.stdout.write(this.getText(this.#store.indent, msgs)); 99 | return this.#store.autoTick ? this.tick(this.#store.indentSize) : this; 100 | } 101 | 102 | /** 103 | * Write indented line to stdout 104 | * * Adds indentation and EOL 105 | * * Returns a stacklogger with extended indentation if `opts.autoTick` else `this` 106 | * @param {...any} msgs Messages to write out 107 | */ 108 | log(...msgs) { 109 | process.stdout.write(this.getText(this.#store.indent, msgs).concat('\n')); 110 | return this.#store.autoTick ? this.tick(this.#store.indentSize) : this; 111 | } 112 | 113 | /** 114 | * Write primarily to stderr with an EOL 115 | * * Adds indentation and EOL 116 | * * Returns a stacklogger with extended indentation if `opts.autoTick` else `this` 117 | * @param {...any} msgs Messages to write out 118 | */ 119 | error(...msgs) { 120 | return this.warn(...msgs); 121 | } 122 | 123 | /** 124 | * Write primarily to stderr with an EOL 125 | * * Adds indentation and EOL 126 | * * Returns a stacklogger with extended indentation if `opts.autoTick` else `this` 127 | * @param {...any} msgs Messages to write out 128 | */ 129 | warn(...msgs) { 130 | process.stderr.write(this.getText(this.#store.indent, msgs).concat('\n')); 131 | return this.#store.autoTick ? this.tick(this.#store.indentSize) : this; 132 | } 133 | 134 | /** 135 | * Generate formated text with proper indentation 136 | * @param {number} [indent] Proper indentation 137 | * @param {string|string[]} [msgs] Message(s) to be written 138 | */ 139 | getText(indent, msgs) { 140 | if (typeof indent === 'object' && !Array.isArray(indent)) ({msgs, indent} = indent); 141 | if (Array.isArray(indent)) [msgs, indent] = [indent, msgs]; 142 | if (typeof indent === 'string') [msgs, indent] = [[indent], msgs]; 143 | indent = typeof indent !== 'number' ? this.#store.indent : indent; 144 | msgs = Array.isArray(msgs) ? msgs : [msgs]; 145 | msgs = indent 146 | ? [this.#store.indentor.repeat(indent).concat(util.formatWithOptions({color: true}, msgs[0])), ...msgs.slice(1)] 147 | : msgs; 148 | return util.formatWithOptions({colors: true}, ...msgs); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/stream_utils.js: -------------------------------------------------------------------------------- 1 | import stream from 'stream'; 2 | import {isBinaryFileSync} from 'isbinaryfile'; 3 | 4 | /** 5 | * Generate a stream transformer splitting at match locations 6 | * @param {string[]} patterns Patterns to be matches within the stream 7 | * @param {boolean} allowBinaryFile Whether or not to scan binary files 8 | */ 9 | function buildSplitter(patterns, allowBinaryFile = false) { 10 | return new stream.Transform({ 11 | defaultEncoding: 'utf8', 12 | objectMode: true, 13 | write(chunk, encoding, callback) { 14 | // merge chunk with buffer, initialize a buffer if none is found 15 | this.buffer = Buffer.concat([this.buffer || Buffer.alloc(0), chunk]); 16 | if (!allowBinaryFile && isBinaryFileSync(this.buffer)) return callback(new Error('Detected binary content in stream')); 17 | // identify which match exists and at what index within the match and in the buffer 18 | let match; 19 | while ( 20 | this.buffer && 21 | this.buffer.length > 0 && 22 | ([match] = patterns 23 | // use reduce as opposed to map-filter for efficiency 24 | .reduce((result, item) => { 25 | if (item) { 26 | // find index of pattern 27 | const index = this.buffer.indexOf(item, 0, encoding); 28 | // push to results if non-zero 29 | if (~index) result.push({index, length: item.length}); 30 | } 31 | return result; 32 | }, []) 33 | // sort matches to identify patterns closest to origin 34 | .sort((a, b) => (a.index > b.index ? 1 : -1)))[0] 35 | ) { 36 | // support empty strings 37 | const adjustedIndex = match.length === 0 ? 1 : match.index; 38 | // push whatever comes before 39 | this.push(this.buffer.slice(0, adjustedIndex)); 40 | // resize the buffer to exclude pushed content and split range 41 | this.buffer = this.buffer.slice(adjustedIndex + match.length); 42 | // delete empty buffers if matching with empty-strings 43 | if (match.length === 0 && this.buffer.length === 0) delete this.buffer; 44 | } 45 | return callback(); 46 | }, 47 | flush(callback) { 48 | callback(null, this.buffer); 49 | }, 50 | }); 51 | } 52 | 53 | /** 54 | * **UNRECOMMENDED**: Synchronously collect chunks into an array of buffers 55 | * @param {NodeJS.ReadableStream} input Readable input stream 56 | * @param {object} [opts] Optional configuration options 57 | * @param {number} [opts.max] Maximum number of bytes to be buffered, reject if larger 58 | * @param {number} [opts.timeout] Timeout to fully read, buffer and return, otherwise reject 59 | */ 60 | function collectBuffers(input, opts) { 61 | return new Promise((res, rej) => { 62 | const result = []; 63 | const spaceErr = new Error(`Error, input stream maxed out allowed space ${opts.max}`); 64 | const timeoutErr = new Error(`Timeout after ${opts.timeout}ms of reading input stream`); 65 | spaceErr.code = 1; 66 | timeoutErr.code = 2; 67 | const self = new stream.Writable({ 68 | write(v, _e, c) { 69 | if (opts.max && (this.bytesBuffered = (this.bytesBuffered || 0) + v.length) > opts.max) c(spaceErr); 70 | else c(null, result.push(v)); 71 | }, 72 | }); 73 | let timeout; 74 | if (opts.timeout) timeout = setTimeout(() => self.destroy(timeoutErr), opts.timeout); 75 | stream.pipeline(input, self, err => { 76 | clearTimeout(timeout); 77 | err ? rej(err) : res(result); 78 | }); 79 | }); 80 | } 81 | 82 | export default { 83 | buildSplitter, 84 | collectBuffers, 85 | }; 86 | -------------------------------------------------------------------------------- /src/symbols.js: -------------------------------------------------------------------------------- 1 | const meta = Symbol('FreyrServiceMeta'); 2 | const fileId = Symbol('FreyrFileId'); 3 | const errorCode = Symbol('FreyrErrorCode'); 4 | const errorStack = Symbol('FreyrErrorStack'); 5 | 6 | export default { 7 | meta, 8 | fileId, 9 | errorCode, 10 | errorStack, 11 | }; 12 | -------------------------------------------------------------------------------- /src/text_utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Stripout invalid characters, symbols and unnecessary spaces 3 | * 4 | * - this will converge all repetitive whitespace to a max of 1 5 | * - this will convert all strings to their lower case equivalents 6 | * - this will automatically remove all repetitions in the array 7 | * 8 | * @param {string[]} data An array of strings to be stripped 9 | * 10 | * @example 11 | * stripText([ 12 | * "$a$B$c$", "#A#b#C", 13 | * "c O n V e R g E" 14 | * ]); // [ "abc", "c o n v e r g e" ] 15 | * 16 | * stripText([ 17 | * "Hello, World!", 18 | * "Hey, I'm David, What's Up?" 19 | * ]); // [ "hello world", "hey im david whats up" ] 20 | */ 21 | function stripText(data) { 22 | return [ 23 | ...new Set( 24 | data.reduce( 25 | (all, text) => 26 | (text = text.normalize('NFD').replace(/\p{Diacritic}|[^\p{Letter} \p{Number}]/gu, '')) 27 | ? all.concat([text.replace(/\s{2,}/g, ' ').toLowerCase()]) 28 | : all, 29 | [], 30 | ), 31 | ), 32 | ]; 33 | } 34 | 35 | /** 36 | * What percentage of text found in `b` is in `a` 37 | * @param {string[]} a the base string to be searched in 38 | * @param {string[]} b the search query 39 | * 40 | * @example 41 | * let a = ["hello", "world", "are", "you", "happy"]; 42 | * let b = ["hello", "dude", "how", "are", "you"]; 43 | * // intersection = ["hello", "are", "you"]; 44 | * // what percentage of `a` is the intersection 45 | * let c = getWeight(a, b); // 60 46 | * 47 | * // AND lookups 48 | * getWeight( 49 | * ["jacob cane", "jessica cane"], 50 | * ["cane"] 51 | * ); // 0 52 | * 53 | * getWeight( 54 | * ["jacob cane", "jessica cane"], 55 | * ["cane", "jessica", "jacob"] 56 | * ); // 100 57 | */ 58 | function getWeight(a, b) { 59 | return ( 60 | ((b = b.join(' ').split(' ')), a.map(v => v.split(' ').every(p => b.includes(p))).filter(v => !!v).length / a.length) * 100 61 | ); 62 | } 63 | 64 | export default {stripText, getWeight}; 65 | -------------------------------------------------------------------------------- /src/walkr.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | 3 | export default function walk(object, ...keys) { 4 | return keys.flat().reduce((base, key) => { 5 | if (Array.isArray(base) && typeof key !== 'number') { 6 | for (const obj of base) { 7 | const result = walk(obj, key); 8 | if (result != null) return result; 9 | } 10 | } else if (typeof base === 'object' && base !== null) { 11 | if (key in base && base[key] != null) return base[key]; 12 | for (const value of Object.values(base)) { 13 | const result = walk(value, key); 14 | if (result != null) return result; 15 | } 16 | } 17 | return null; 18 | }, object); 19 | } 20 | -------------------------------------------------------------------------------- /test/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "spotify": { 3 | "track": { 4 | "uri": "https://open.spotify.com/track/5FNS5Vj69AhRGJWjhrAd01", 5 | "expect": [ 6 | "[•] Collation Complete", 7 | "[•] Total tracks: [01]", 8 | "✓ Passed: [01]", 9 | "✕ Failed: [00]" 10 | ] 11 | }, 12 | "album": { 13 | "uri": "https://open.spotify.com/album/2D23kwwoy2JpZVuJwzE42B", 14 | "expect": [ 15 | "[•] Collation Complete", 16 | "[•] Total tracks: [04]", 17 | "✓ Passed: [04]", 18 | "✕ Failed: [00]" 19 | ] 20 | }, 21 | "artist": { 22 | "uri": "https://open.spotify.com/artist/4adSXA1GDOxNG7Zw89YHyz", 23 | "filter": [ 24 | "album=\"the rainbow cassette\"", 25 | "album=\"make believe\"" 26 | ], 27 | "expect": [ 28 | "[•] Collation Complete", 29 | "✓ Passed: [10]", 30 | "✕ Failed: [00]" 31 | ] 32 | }, 33 | "playlist": { 34 | "uri": "https://open.spotify.com/playlist/5KcCGEx7fFqXYNiJkSQ5KT", 35 | "expect": [ 36 | "[•] Collation Complete", 37 | "✓ Passed: [05]", 38 | "✕ Failed: [00]" 39 | ] 40 | } 41 | }, 42 | "apple_music": { 43 | "track": { 44 | "uri": "https://music.apple.com/us/album/elio-irl/1547735824?i=1547736100", 45 | "expect": [ 46 | "[•] Collation Complete", 47 | "[•] Total tracks: [01]", 48 | "✓ Passed: [01]", 49 | "✕ Failed: [00]" 50 | ] 51 | }, 52 | "album": { 53 | "uri": "https://music.apple.com/us/album/im-sorry-im-not-sorry-ep/1491795443", 54 | "expect": [ 55 | "[•] Collation Complete", 56 | "[•] Total tracks: [04]", 57 | "✓ Passed: [04]", 58 | "✕ Failed: [00]" 59 | ] 60 | }, 61 | "artist": { 62 | "uri": "https://music.apple.com/us/artist/mazie/1508029053", 63 | "filter": [ 64 | "album=\"the rainbow cassette\"", 65 | "album=\"make believe\"" 66 | ], 67 | "expect": [ 68 | "[•] Collation Complete", 69 | "✓ Passed: [10]", 70 | "✕ Failed: [00]" 71 | ] 72 | }, 73 | "playlist": { 74 | "uri": "https://music.apple.com/us/playlist/songs-from-up-next-bazzi/pl.56c4bdf909954beca0cf69379a48144f", 75 | "expect": [ 76 | "[•] Collation Complete", 77 | "✓ Passed: [06]", 78 | "✕ Failed: [00]" 79 | ] 80 | } 81 | }, 82 | "deezer": { 83 | "track": { 84 | "uri": "https://www.deezer.com/en/track/1189202982", 85 | "expect": [ 86 | "[•] Collation Complete", 87 | "[•] Total tracks: [01]", 88 | "✓ Passed: [01]", 89 | "✕ Failed: [00]" 90 | ] 91 | }, 92 | "album": { 93 | "uri": "https://www.deezer.com/en/album/123330212", 94 | "expect": [ 95 | "[•] Collation Complete", 96 | "[•] Total tracks: [04]", 97 | "✓ Passed: [04]", 98 | "✕ Failed: [00]" 99 | ] 100 | }, 101 | "artist": { 102 | "uri": "https://www.deezer.com/en/artist/14808825", 103 | "filter": [ 104 | "album=\"the rainbow cassette\"", 105 | "album=\"make believe\"" 106 | ], 107 | "expect": [ 108 | "[•] Collation Complete", 109 | "✓ Passed: [10]", 110 | "✕ Failed: [00]" 111 | ] 112 | }, 113 | "playlist": { 114 | "uri": "https://www.deezer.com/en/playlist/10004168842", 115 | "expect": [ 116 | "[•] Collation Complete", 117 | "✓ Passed: [05]", 118 | "✕ Failed: [00]" 119 | ] 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import url from 'url'; 2 | import util from 'util'; 3 | import {tmpdir} from 'os'; 4 | import {randomBytes} from 'crypto'; 5 | import {PassThrough} from 'stream'; 6 | import {spawn} from 'child_process'; 7 | import {relative, join, resolve} from 'path'; 8 | import {promises as fs, constants as fs_constants, createWriteStream} from 'fs'; 9 | 10 | import fileMgr from '../src/file_mgr.js'; 11 | 12 | const maybeStat = path => fs.stat(path).catch(() => false); 13 | 14 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); 15 | 16 | function sed(fn) { 17 | return new PassThrough({ 18 | write(chunk, _, cb) { 19 | this.buf = Buffer.concat([this.buf || Buffer.alloc(0), chunk]); 20 | let eol; 21 | while (~(eol = this.buf.indexOf(0x0a))) { 22 | this.push(fn(this.buf.slice(0, eol + 1))); 23 | this.buf = this.buf.slice(eol + 1); 24 | } 25 | cb(null); 26 | }, 27 | final(cb) { 28 | this.push(this.buf); 29 | cb(); 30 | }, 31 | }); 32 | } 33 | 34 | function tee(stream1, stream2) { 35 | let stream = new PassThrough(); 36 | stream.pipe(stream1); 37 | stream.pipe(stream2); 38 | return stream; 39 | } 40 | 41 | let abortCode = Symbol('ExitCode'); 42 | 43 | async function pRetry(tries, fn) { 44 | let result, 45 | rawErr, 46 | abortSymbol = Symbol('RetryAbort'); 47 | for (let [i] of Array.apply(null, {length: tries}).entries()) { 48 | try { 49 | return await fn(i + 1, rawErr, blob => { 50 | if (blob && abortCode in blob) (result = Promise.reject(blob)).catch(() => {}); 51 | throw abortSymbol; 52 | }); 53 | } catch (err) { 54 | if (err === abortSymbol) break; 55 | (result = Promise.reject((rawErr = err))).catch(() => {}); 56 | } 57 | } 58 | return result; 59 | } 60 | 61 | function short_path(path) { 62 | let a = resolve(path); 63 | let b = relative(process.cwd(), path); 64 | if (!['..', '/'].some(c => b.startsWith(c))) b = `./${b}`; 65 | return a.length < b.length ? a : b; 66 | } 67 | 68 | const default_stage = join(tmpdir(), 'freyr-test'); 69 | 70 | async function run_tests(suite, args, i) { 71 | let docker_image; 72 | if (~(i = args.indexOf('--docker')) && !(docker_image = args.splice(i, 2)[1])) 73 | throw new Error('`--docker` requires an image name'); 74 | let stage_name = randomBytes(6).toString('hex'); 75 | if (~(i = args.indexOf('--name')) && !(stage_name = args.splice(i, 2)[1])) throw new Error('`--name` requires a stage name'); 76 | let stage_path = default_stage; 77 | if (~(i = args.indexOf('--stage')) && !(stage_path = args.splice(i, 2)[1])) throw new Error('`--stage` requires a path'); 78 | stage_path = resolve(join(stage_path, stage_name)); 79 | let force, clean; 80 | if ((force = !!~(i = args.indexOf('--force')))) args.splice(i, 1); 81 | if ((clean = !!~(i = args.indexOf('--clean')))) args.splice(i, 1); 82 | if (await maybeStat(stage_path)) 83 | if (!force) throw new Error(`stage path [${stage_path}] already exists`); 84 | else if (clean) await fs.rm(stage_path, {recursive: true}); 85 | 86 | let is_gha = 'GITHUB_ACTIONS' in process.env && process.env['GITHUB_ACTIONS'] === 'true'; 87 | 88 | let tests = args; 89 | if (~(i = args.indexOf('--all'))) args.splice(i, 1), (tests = Object.keys(suite)); 90 | let invalidArg; 91 | if ((invalidArg = args.find(arg => arg.startsWith('-')))) throw new Error(`Invalid argument: ${invalidArg}`); 92 | 93 | if (!tests.length) return noService; 94 | 95 | for (let [i, test] of tests.entries()) { 96 | let [service, type] = test.split('.'); 97 | if (!(service in suite)) throw new Error(`Invalid service: ${service}`); 98 | if (!type) { 99 | tests.splice(i + 1, 0, ...Object.keys(suite[service]).map(type => `${test}.${type}`)); 100 | continue; 101 | } 102 | 103 | let {uri, filter = [], expect} = suite[service][type]; 104 | 105 | let test_stage_path = join(stage_path, test); 106 | 107 | let preargs = ['--no-logo', '--no-header', '--no-bar']; 108 | if (is_gha) preargs.push('--no-auth'); 109 | let child_args = [...preargs, ...filter.map(f => `--filter=${f}`)]; 110 | 111 | let unmetExpectations = new Error('One or more expectations failed'); 112 | 113 | await pRetry(3, async (attempt, lastErr, abort) => { 114 | if (attempt > 1 && lastErr !== unmetExpectations) abort(); 115 | 116 | let logFile = await fileMgr({ 117 | path: join(test_stage_path, `${service}-${type}-${attempt}.log`), 118 | keep: true, 119 | }).open(fs_constants.O_WRONLY | fs_constants.O_TRUNC); 120 | 121 | logFile.stream = createWriteStream(null, {fd: logFile.handle}); 122 | 123 | let logline = line => `│ ${line}`; 124 | 125 | let raw_stdout = tee(logFile.stream, process.stdout); 126 | let stdout = sed(logline); 127 | stdout.pipe(raw_stdout); 128 | 129 | let raw_stderr = tee(logFile.stream, process.stderr); 130 | let stderr = sed(logline); 131 | stderr.pipe(raw_stderr); 132 | 133 | stdout.log = (...args) => void stdout.write(util.formatWithOptions({colors: true}, ...args, '\n')); 134 | stderr.log = (...args) => void stderr.write(util.formatWithOptions({colors: true}, ...args, '\n')); 135 | 136 | if (attempt > 1) 137 | if (is_gha) console.log(`::warning::[${attempt}/3] Download failed, retrying..`); 138 | else console.log(`\x1b[33m[${attempt}/3] Download failed, retrying..\x1b[0m`); 139 | console.log(`Log File: ${logFile.path}`); 140 | 141 | let top_bar = `┌──> ${`[${attempt}/3] ${service} ${type} `.padEnd(56, '─')}┐`; 142 | if (is_gha) console.log(`::group::${top_bar}`); 143 | else raw_stdout.write(`${top_bar}\n`); 144 | 145 | let child, 146 | handler = () => {}; 147 | 148 | let extra_node_args = process.env['NODE_ARGS'] ? process.env['NODE_ARGS'].split(' ') : []; 149 | if (!docker_image) { 150 | child = spawn( 151 | 'node', 152 | [ 153 | ...extra_node_args, 154 | '--', 155 | short_path(join(__dirname, '..', 'cli.js')), 156 | ...child_args, 157 | '--directory', 158 | short_path(test_stage_path), 159 | uri, 160 | ], 161 | {...process.env, SHOW_DEBUG_STACK: 1}, 162 | ); 163 | } else { 164 | let child_id = `${test}.${stage_name}`; 165 | let extra_docker_args = process.env['DOCKER_ARGS'] ? process.env['DOCKER_ARGS'].split(' ') : []; 166 | child = spawn('docker', [ 167 | 'run', 168 | ...extra_docker_args, 169 | '--rm', 170 | '--interactive', 171 | '--log-driver=none', 172 | '--name', 173 | child_id, 174 | '--network', 175 | 'host', 176 | '--volume', 177 | `${test_stage_path}:/data`, 178 | '--env', 179 | 'SHOW_DEBUG_STACK=1', 180 | ...(extra_node_args.length ? ['--env', `FREYR_NODE_ARGS=${extra_node_args.join(' ')}`] : []), 181 | '--', 182 | docker_image, 183 | ...child_args, 184 | uri, 185 | ]); 186 | handler = () => spawn('docker', ['kill', child_id]); 187 | } 188 | 189 | let sigint_handler, sigterm_handler, sighup_handler; 190 | process 191 | .on('SIGINT', (sigint_handler = () => (handler(), close_handler(130)))) 192 | .on('SIGTERM', (sigterm_handler = () => (handler(), close_handler(143)))) 193 | .on('SIGHUP', (sighup_handler = () => (handler(), close_handler(129)))); 194 | 195 | stdout.log(`\n$ ${child.spawnargs.join(' ')}\n`); 196 | 197 | let childErrors = []; 198 | child.on('error', err => childErrors.push(err)); 199 | 200 | let logs = []; 201 | 202 | for (let [i, o] of [ 203 | [child.stdout, stdout], 204 | [child.stderr, stderr], 205 | ]) 206 | i.on('data', data => { 207 | let line = data.toString(); 208 | let pos; 209 | if (~(pos = line.indexOf('\x1b[G'))) line = line.slice(0, pos + 3) + logline(line.slice(pos + 3)); 210 | logs.push(line); 211 | o.write(line); 212 | }); 213 | 214 | let closed, close_handler; 215 | await new Promise((res, rej) => { 216 | child.on( 217 | 'close', 218 | (close_handler = (code, err) => { 219 | if (closed) return; 220 | closed = true; 221 | child.off('close', close_handler); 222 | process.off('SIGINT', sigint_handler).off('SIGTERM', sigterm_handler).off('SIGHUP', sighup_handler); 223 | if (docker_image && code === 137) abort({[abortCode]: 130}); 224 | for (let [signal, signame] of [ 225 | [130, 'SIGINT'], 226 | [143, 'SIGTERM'], 227 | [129, 'SIGHUP'], 228 | ]) 229 | if (code === signal) process.kill(process.pid, signame); 230 | if (code !== 0) err = new Error(`child process exited with code ${code}`); 231 | else if (childErrors.length) err = childErrors.shift(); 232 | if (!err) res(); 233 | else { 234 | err.code = code; 235 | if (childErrors.length) err[errorCauses] = childErrors; 236 | rej(err); 237 | } 238 | }), 239 | ); 240 | }); 241 | 242 | if (is_gha && (i = logs.findIndex(line => line.includes('[•] Embedding Metadata')))) { 243 | console.log(`::group::├──> ${`[${attempt}/3] View Download Status `.padEnd(56, '─')}┤`); 244 | for (let line of logs 245 | .slice(i) 246 | .join('') 247 | .split('\n') 248 | .filter(line => line.trim().length)) 249 | console.log(`│ ${line}`); 250 | console.log('::endgroup::'); 251 | } 252 | 253 | let ml = expect.reduce((a, v) => Math.max(a, v.length), 0); 254 | if (is_gha) console.log(`::group::├──> ${`[${attempt}/3] Verifying... `.padEnd(56, '─')}┤`); 255 | else raw_stdout.write(`├──> ${`[${attempt}/3] Verifying... `.padEnd(56, '─')}┤\n`); 256 | let as_expected = true; 257 | for (let expected of expect) { 258 | stdout.write(`• \x1b[33m${expected.padEnd(ml + 2, ' ')}\x1b[0m `); 259 | let this_passed; 260 | if ((this_passed = logs.some(line => line.match(new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))))) 261 | stdout.log('\x1b[32m(matched)\x1b[0m'); 262 | else stdout.log('\x1b[31m(failed to match)\x1b[0m'); 263 | as_expected &&= this_passed; 264 | } 265 | if (is_gha) console.log('::endgroup::'); 266 | if (is_gha) console.log(`::group::└${'─'.repeat(top_bar.length - 2)}┘\n::endgroup::`); 267 | else raw_stdout.write(`└${'─'.repeat(top_bar.length - 2)}┘\n`); 268 | 269 | if (!as_expected) throw unmetExpectations; 270 | }); 271 | } 272 | } 273 | 274 | let errorCauses = Symbol('ErrorCauses'); 275 | let noService = Symbol('noService'); 276 | 277 | async function main(args) { 278 | let suite, test_suite, i; 279 | 280 | if (~(i = args.indexOf('--suite')) && !(test_suite = args.splice(i, 2)[1])) throw new Error('`--suite` requires a file path'); 281 | 282 | suite = JSON.parse(await fs.readFile(test_suite || join(__dirname, 'default.json'))); 283 | 284 | if (!['-h', '--help'].some(args.includes.bind(args))) { 285 | let exitCode; 286 | try { 287 | if (noService !== (await run_tests(suite, args))) return; 288 | } catch (err) { 289 | if (abortCode in err) exitCode = err[abortCode]; 290 | else { 291 | console.error('An error occurred!'); 292 | if (errorCauses in err) { 293 | let causes = err[errorCauses]; 294 | delete err[errorCauses]; 295 | console.error('', err); 296 | for (let cause of causes) console.error('', cause); 297 | } else console.error('', err); 298 | exitCode = 1; 299 | } 300 | } finally { 301 | await fileMgr.garbageCollect(); 302 | } 303 | if (exitCode) process.exit(exitCode); 304 | } 305 | 306 | console.log('freyr-test'); 307 | console.log('----------'); 308 | console.log('Usage: freyr-test [options] [[.]...]'); 309 | console.log(); 310 | console.log('Utility for testing the Freyr CLI'); 311 | console.log(); 312 | console.log('Options:'); 313 | console.log(); 314 | console.log(` SERVICE ${Object.keys(suite).join(' / ')}`); 315 | console.log(` TYPE ${[...new Set(Object.values(suite).flatMap(s => Object.keys(s)))].join(' / ')}`); 316 | console.log(); 317 | console.log(' --all run all tests'); 318 | console.log(' --suite use a specific test suite (json)'); 319 | console.log(' --docker run tests in a docker container'); 320 | console.log(' --name name for this test run (defaults to a random hex string)'); 321 | console.log(` --stage directory to stage this test (default: ${default_stage})`); 322 | console.log(' --force allow reusing existing stages'); 323 | console.log(' --clean (when --force is used) clean existing stage before reusing it'); 324 | console.log(' --help show this help message'); 325 | console.log(); 326 | console.log('Enviroment Variables:'); 327 | console.log(); 328 | console.log(' DOCKER_ARGS arguments to pass to `docker run`'); 329 | console.log(' NODE_ARGS arguments to pass to `node`'); 330 | console.log(); 331 | console.log('Example:'); 332 | console.log(); 333 | console.log(' $ freyr-test --all'); 334 | console.log(' runs all tests'); 335 | console.log(); 336 | console.log(' $ freyr-test spotify'); 337 | console.log(' runs all Spotify tests'); 338 | console.log(); 339 | console.log(' $ freyr-test apple_music.album'); 340 | console.log(' tests downloading an Apple Music album'); 341 | console.log(); 342 | console.log(' $ freyr-test spotify.track deezer.artist'); 343 | console.log(' tests downloading a Spotify track and Deezer artist'); 344 | console.log(); 345 | console.log(' $ freyr-test spotify.track --stage ./stage --name test-run'); 346 | console.log(' downloads the Spotify test track in ./stage/test-run/spotify.track with logs'); 347 | } 348 | 349 | function _start() { 350 | main(process.argv.slice(2)); 351 | } 352 | 353 | _start(); 354 | -------------------------------------------------------------------------------- /test/urify.js: -------------------------------------------------------------------------------- 1 | import FreyrCore from '../src/freyr.js'; 2 | 3 | let corpus = [ 4 | { 5 | url: 'https://open.spotify.com/track/127QTOFJsJQp5LbJbu3A1y', 6 | uri: 'spotify:track:127QTOFJsJQp5LbJbu3A1y', 7 | }, 8 | { 9 | url: 'https://open.spotify.com/album/623PL2MBg50Br5dLXC9E9e', 10 | uri: 'spotify:album:623PL2MBg50Br5dLXC9E9e', 11 | }, 12 | { 13 | url: 'https://open.spotify.com/artist/6M2wZ9GZgrQXHCFfjv46we', 14 | uri: 'spotify:artist:6M2wZ9GZgrQXHCFfjv46we', 15 | }, 16 | { 17 | url: 'https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M', 18 | uri: 'spotify:playlist:37i9dQZF1DXcBWIGoYBM5M', 19 | }, 20 | { 21 | url: 'https://music.apple.com/us/album/say-so-feat-nicki-minaj/1510821672?i=1510821685', 22 | uri: 'apple_music:track:1510821685', 23 | }, 24 | { 25 | url: 'https://music.apple.com/us/song/1510821685', 26 | uri: 'apple_music:track:1510821685', 27 | }, 28 | { 29 | url: 'https://music.apple.com/us/album/birds-of-prey-the-album/1493581254', 30 | uri: 'apple_music:album:1493581254', 31 | }, 32 | { 33 | url: 'https://music.apple.com/us/artist/412778295', 34 | uri: 'apple_music:artist:412778295', 35 | }, 36 | { 37 | url: 'https://music.apple.com/us/playlist/todays-hits/pl.f4d106fed2bd41149aaacabb233eb5eb', 38 | uri: 'apple_music:playlist:pl.f4d106fed2bd41149aaacabb233eb5eb', 39 | }, 40 | { 41 | url: 'https://www.deezer.com/en/track/642674232', 42 | uri: 'deezer:track:642674232', 43 | }, 44 | { 45 | url: 'https://www.deezer.com/en/album/99687992', 46 | uri: 'deezer:album:99687992', 47 | }, 48 | { 49 | url: 'https://www.deezer.com/en/artist/5340439', 50 | uri: 'deezer:artist:5340439', 51 | }, 52 | { 53 | url: 'https://www.deezer.com/en/playlist/1963962142', 54 | uri: 'deezer:playlist:1963962142', 55 | }, 56 | ]; 57 | 58 | function main() { 59 | for (let item of corpus) { 60 | for (let key in item) { 61 | let parsed = FreyrCore.parseURI(item[key]); 62 | if (parsed) { 63 | console.log(`⏩┬[ \x1b[36m${item[key]}\x1b[39m ]`); 64 | if (parsed.uri === item.uri) { 65 | console.log(` ├ ✅ asURI -> \x1b[36m${parsed.uri}\x1b[39m`); 66 | } else { 67 | console.log(` ├ ❌ asURI -> \x1b[36m${parsed.uri}\x1b[39m (expected \x1b[33m${item.uri}\x1b[39m)`); 68 | } 69 | if (parsed.url === item.url) { 70 | console.log(` └ ✅ asURL -> \x1b[36m${parsed.url}\x1b[39m`); 71 | } else { 72 | console.log(` └ ❌ asURL -> \x1b[36m${parsed.url}\x1b[39m (expected \x1b[33m${item.url}\x1b[39m)`); 73 | } 74 | } else { 75 | console.log(`❌─[ \x1b[36m${item[key]}\x1b[39m ]`); 76 | } 77 | } 78 | 79 | console.log(); 80 | } 81 | } 82 | 83 | main(); 84 | --------------------------------------------------------------------------------