├── .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] | [][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] | [][base-url] |
229 | | :-: | - |
230 | | [**This Patch**][pr-url] | [][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('