├── .changeset ├── README.md └── config.json ├── .commitlintrc.json ├── .dockerignore ├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── build-publish-docker.yml │ ├── build-release-cli.yml │ ├── build-release-electron.yml │ ├── create-release-or-publish.yml │ └── pull-request.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── Dockerfile ├── LICENSE.txt ├── README.md ├── cli ├── .gitignore ├── CHANGELOG.md ├── README.md ├── package.json ├── src │ ├── bitrate.test.ts │ ├── bitrate.ts │ ├── download.ts │ ├── login.ts │ └── main.ts ├── tsconfig.json └── vitest.config.ts ├── deemix ├── CHANGELOG.md ├── package.json ├── src │ ├── decryption.ts │ ├── download-objects │ │ ├── Collection.ts │ │ ├── DownloadObject.ts │ │ ├── Single.ts │ │ ├── generateAlbumItem.ts │ │ ├── generateArtistTopItem.ts │ │ ├── generatePlaylistItem.ts │ │ ├── generateTrackItem.ts │ │ └── index.ts │ ├── downloader.ts │ ├── errors.ts │ ├── index.ts │ ├── plugins │ │ ├── base.ts │ │ ├── index.ts │ │ └── spotify.ts │ ├── settings.ts │ ├── tagger.ts │ ├── types │ │ ├── Album.ts │ │ ├── Artist.ts │ │ ├── CustomDate.ts │ │ ├── Lyrics.ts │ │ ├── Picture.ts │ │ ├── Playlist.ts │ │ ├── Settings.ts │ │ ├── Track.ts │ │ ├── index.ts │ │ └── listener.ts │ └── utils │ │ ├── blowfish.cjs │ │ ├── core.ts │ │ ├── crypto.ts │ │ ├── deezer.ts │ │ ├── downloadImage.ts │ │ ├── downloadUtils.ts │ │ ├── getPreferredBitrate.ts │ │ ├── index.ts │ │ ├── localpaths.ts │ │ ├── pathtemplates.test.ts │ │ └── pathtemplates.ts ├── tsconfig.json └── vitest.config.ts ├── deezer-sdk ├── CHANGELOG.md ├── package.json ├── src │ ├── api.ts │ ├── deezer.ts │ ├── errors.ts │ ├── gw.ts │ ├── index.ts │ ├── schema │ │ ├── album-schema.ts │ │ ├── contributor-schema.ts │ │ ├── playlist-schema.ts │ │ └── track-schema.ts │ ├── store.ts │ ├── tests │ │ └── api.test.ts │ ├── types.ts │ └── utils.ts ├── tsconfig.json └── vitest.config.ts ├── docker-compose.yml ├── docker └── etc │ ├── cont-init.d │ ├── 10-fix_folders │ └── 15-checks │ └── services.d │ └── deemix │ └── run ├── eslint.config.mjs ├── gui ├── CHANGELOG.md ├── build │ ├── 64x64.png │ ├── icon.icns │ ├── icon.ico │ └── icon.svg ├── forge.config.js ├── package.json ├── scripts │ ├── build.js │ ├── cjs-shim.js │ └── utils.js ├── src │ ├── main.ts │ └── preload.ts └── tsconfig.json ├── lint-staged.config.mjs ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── prettier.config.mjs ├── turbo.json ├── vercel.json ├── vitest.workspace.ts └── webui ├── CHANGELOG.md ├── README.md ├── index.html ├── package.json ├── postcss.config.cjs ├── public ├── favicon.png ├── fonts │ ├── OpenSans │ │ ├── LICENSE.txt │ │ ├── mem5YaGs126MiZpBA-UN7rgOUehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN7rgOUuhp.woff2 │ │ ├── mem5YaGs126MiZpBA-UN7rgOVuhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN7rgOX-hpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN7rgOXOhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN7rgOXehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN7rgOXuhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN8rsOUehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN8rsOUuhp.woff2 │ │ ├── mem5YaGs126MiZpBA-UN8rsOVuhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN8rsOX-hpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN8rsOXOhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN8rsOXehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN8rsOXuhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 │ │ ├── mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UNirkOUuhp.woff2 │ │ ├── mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 │ │ ├── mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 │ │ ├── mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 │ │ ├── mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 │ │ ├── mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 │ │ ├── mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 │ │ ├── mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 │ │ ├── mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 │ │ ├── mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 │ │ ├── mem8YaGs126MiZpBA-UFUZ0bbck.woff2 │ │ ├── mem8YaGs126MiZpBA-UFVZ0b.woff2 │ │ ├── mem8YaGs126MiZpBA-UFVp0bbck.woff2 │ │ ├── mem8YaGs126MiZpBA-UFW50bbck.woff2 │ │ ├── mem8YaGs126MiZpBA-UFWJ0bbck.woff2 │ │ ├── mem8YaGs126MiZpBA-UFWZ0bbck.woff2 │ │ ├── mem8YaGs126MiZpBA-UFWp0bbck.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKW-U9hkIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKW-U9hlIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKW-U9hmIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKW-U9hnIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKW-U9hoIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKW-U9hrIqM.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKW-U9hvIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWiUNhkIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWiUNhlIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWiUNhmIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWiUNhnIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWiUNhoIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWiUNhrIqM.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWiUNhvIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 │ │ ├── memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 │ │ └── memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 │ └── icons │ │ └── MaterialIcons-Regular.ttf └── res │ └── InfoSpotifyFeatures │ ├── ClientIdSecret.png │ ├── CreateApp.png │ └── CreateAppForm.png ├── src ├── client │ ├── App.vue │ ├── assets │ │ ├── ar.svg │ │ └── deemix-icon.svg │ ├── components │ │ ├── TheContent.vue │ │ ├── TheSearchBar.vue │ │ ├── TheSidebar.vue │ │ ├── downloads │ │ │ ├── QueueItem.vue │ │ │ └── TheDownloadBar.vue │ │ ├── globals │ │ │ ├── BackButton.vue │ │ │ ├── BaseAccordion.vue │ │ │ ├── BaseLoadingPlaceholder.vue │ │ │ ├── BaseTab.vue │ │ │ ├── BaseTabs.vue │ │ │ ├── CoverContainer.vue │ │ │ ├── DeezerWarning.vue │ │ │ ├── PreviewControls.vue │ │ │ ├── TheContextMenu.vue │ │ │ ├── TheQualityModal.vue │ │ │ └── TheTrackPreview.vue │ │ ├── search │ │ │ ├── ResultsAlbums.vue │ │ │ ├── ResultsAll.vue │ │ │ ├── ResultsArtists.vue │ │ │ ├── ResultsError.vue │ │ │ ├── ResultsPlaylists.vue │ │ │ ├── ResultsTracks.vue │ │ │ └── TopResult.vue │ │ └── settings │ │ │ └── TemplateVariablesList.vue │ ├── data │ │ ├── artist.ts │ │ ├── charts.ts │ │ ├── file-templates.ts │ │ ├── home.ts │ │ ├── qualities.ts │ │ ├── search.ts │ │ ├── settings.ts │ │ ├── sidebar.ts │ │ └── standardize.ts │ ├── electron-window.d.ts │ ├── env.d.ts │ ├── lang │ │ ├── ar.mjs │ │ ├── de.mjs │ │ ├── el.mjs │ │ ├── en.mjs │ │ ├── es.mjs │ │ ├── fil.mjs │ │ ├── fr.mjs │ │ ├── hr.mjs │ │ ├── id.mjs │ │ ├── index.js │ │ ├── it.mjs │ │ ├── ko.mjs │ │ ├── pl.mjs │ │ ├── pt-br.mjs │ │ ├── pt-pt.mjs │ │ ├── ru.mjs │ │ ├── sr.mjs │ │ ├── th.mjs │ │ ├── tr.mjs │ │ ├── vi.mjs │ │ └── zh-tw.mjs │ ├── main.ts │ ├── plugins │ │ └── i18n.ts │ ├── router │ │ └── index.ts │ ├── stores │ │ ├── appInfo.ts │ │ ├── errors.ts │ │ ├── index.ts │ │ └── login.ts │ ├── styles │ │ ├── css │ │ │ ├── global.css │ │ │ ├── helpers.css │ │ │ ├── icons.css │ │ │ ├── normalize.css │ │ │ ├── tables.css │ │ │ ├── toasts.css │ │ │ └── typography.css │ │ └── vendor │ │ │ ├── OpenSans.css │ │ │ ├── animate.css │ │ │ └── material-icons.css │ ├── tests │ │ ├── testlang.js │ │ └── unit │ │ │ └── utils │ │ │ ├── dates.test.ts │ │ │ ├── downloads.test.ts │ │ │ ├── texts.test.ts │ │ │ └── utils.test.ts │ ├── tsconfig.json │ ├── use │ │ ├── favorites.ts │ │ ├── main-search.ts │ │ ├── online.ts │ │ ├── search.ts │ │ └── theme.ts │ ├── utils │ │ ├── adjust-volume.ts │ │ ├── api-utils.ts │ │ ├── countries.ts │ │ ├── dates.ts │ │ ├── downloads.ts │ │ ├── emitter.ts │ │ ├── flags.ts │ │ ├── forms.ts │ │ ├── socket.ts │ │ ├── texts.ts │ │ ├── toasts.ts │ │ └── utils.ts │ └── views │ │ ├── AboutView.vue │ │ ├── ArtistView.vue │ │ ├── ChartsView.vue │ │ ├── ErrorsView.vue │ │ ├── FavoritesView.vue │ │ ├── HomeView.vue │ │ ├── InfoArl.vue │ │ ├── InfoSpotifyFeatures.vue │ │ ├── LinkAnalyzer.vue │ │ ├── SearchView.vue │ │ ├── SettingsPage.vue │ │ └── TracklistView.vue └── server │ ├── deemixApp.ts │ ├── env.d.ts │ ├── helpers │ ├── errors.ts │ ├── logger.ts │ ├── loginStorage.ts │ ├── port.ts │ ├── primitive-checks.ts │ ├── server-callbacks.ts │ ├── versions.ts │ └── web-version.ts │ ├── main.ts │ ├── routes │ ├── api │ │ ├── delete │ │ │ └── index.ts │ │ ├── get │ │ │ ├── albumSearch.test.ts │ │ │ ├── albumSearch.ts │ │ │ ├── analyzeLink.test.ts │ │ │ ├── analyzeLink.ts │ │ │ ├── checkForUpdates.ts │ │ │ ├── connect.ts │ │ │ ├── getChartTracks.ts │ │ │ ├── getCharts.ts │ │ │ ├── getHome.ts │ │ │ ├── getQueue.ts │ │ │ ├── getSettings.ts │ │ │ ├── getTracklist.ts │ │ │ ├── getUserAlbums.ts │ │ │ ├── getUserArtists.ts │ │ │ ├── getUserFavorites.ts │ │ │ ├── getUserPlaylists.ts │ │ │ ├── getUserSpotifyPlaylists.ts │ │ │ ├── getUserTracks.ts │ │ │ ├── index.ts │ │ │ ├── mainSearch.ts │ │ │ ├── newReleases.ts │ │ │ ├── search.ts │ │ │ └── spotifyStatus.ts │ │ ├── patch │ │ │ └── index.ts │ │ ├── post │ │ │ ├── addToQueue.ts │ │ │ ├── cancelAllDownloads.ts │ │ │ ├── changeAccount.test.ts │ │ │ ├── changeAccount.ts │ │ │ ├── index.ts │ │ │ ├── loginArl.test.ts │ │ │ ├── loginArl.ts │ │ │ ├── loginEmail.ts │ │ │ ├── logout.ts │ │ │ ├── removeFinishedDownloads.ts │ │ │ ├── removeFromQueue.ts │ │ │ ├── retryDownload.ts │ │ │ └── saveSettings.ts │ │ └── register.ts │ └── index.ts │ ├── tests │ └── utils.ts │ ├── tsconfig.json │ ├── types.ts │ └── websocket │ ├── index.ts │ └── modules │ ├── cancelAllDownloads.ts │ ├── index.ts │ ├── removeFinishedDownloads.ts │ ├── removeFromQueue.ts │ └── saveSettings.ts ├── tailwind.config.cjs ├── tsconfig.config.json ├── tsconfig.json ├── vite.config.mts └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [], 11 | "privatePackages": { 12 | "version": true, 13 | "tag": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "scope-case": [2, "always", "lower-case"], 5 | "scope-empty": [2, "never"], 6 | "scope-enum": [ 7 | 2, 8 | "always", 9 | ["deezer-sdk", "deemix", "cli", "webui", "gui", "repo"] 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **node_modules/ 2 | **dist/ 3 | gui/ 4 | **/tsconfig.tsbuildinfo 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @bambanah -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What? 2 | 3 | ## Why? 4 | 5 | ## How? 6 | 7 | ## Screenshots (if applicable) 8 | -------------------------------------------------------------------------------- /.github/workflows/build-publish-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker 2 | 3 | on: 4 | push: 5 | tags: 6 | - deemix-webui@* 7 | pull_request: 8 | branches: 9 | - main 10 | paths: 11 | - .github/workflows/build-publish-docker.yml 12 | - Dockerfile 13 | - docker/** 14 | - webui/CHANGELOG.md 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | env: 21 | HUSKY: 0 22 | REGISTRY: ghcr.io 23 | IMAGE_NAME: ${{ github.repository }} 24 | PLATFORMS: linux/amd64,linux/arm64 25 | 26 | jobs: 27 | build-publish-docker: 28 | runs-on: ubuntu-latest 29 | 30 | permissions: 31 | contents: read 32 | packages: write 33 | attestations: write 34 | id-token: write 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v3 41 | 42 | - name: Set up Docker Buildx 43 | uses: docker/setup-buildx-action@v3 44 | 45 | - name: Log in to the Container registry 46 | uses: docker/login-action@v3 47 | with: 48 | registry: ${{ env.REGISTRY }} 49 | username: ${{ github.actor }} 50 | password: ${{ secrets.GITHUB_TOKEN }} 51 | 52 | - name: Extract metadata (tags, labels) for Docker 53 | id: meta 54 | uses: docker/metadata-action@v5 55 | with: 56 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 57 | tags: | 58 | type=ref,event=branch 59 | type=match,pattern=\d+\.\d+\.\d+,prefix=v 60 | 61 | - name: Build and push Docker image 62 | id: push 63 | uses: docker/build-push-action@v6 64 | with: 65 | context: . 66 | platforms: ${{ env.PLATFORMS }} 67 | cache-from: type=gha 68 | cache-to: type=gha,mode=max 69 | push: ${{ startsWith(github.ref, 'refs/tags/') }} 70 | tags: ${{ steps.meta.outputs.tags }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | build-args: | 73 | TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} 74 | TURBO_TEAM=${{ vars.TURBO_TEAM }} 75 | -------------------------------------------------------------------------------- /.github/workflows/build-release-cli.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish CLI 2 | 3 | on: 4 | push: 5 | tags: 6 | - deemix-cli@* 7 | pull_request: 8 | branches: 9 | - main 10 | paths: 11 | - cli/CHANGELOG.md 12 | - .github/workflows/build-release-cli.yml 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | env: 19 | HUSKY: 0 20 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 21 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 22 | 23 | jobs: 24 | build-release-cli: 25 | runs-on: macos-latest 26 | 27 | permissions: 28 | contents: write 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: pnpm/action-setup@v4 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: 20 36 | cache: pnpm 37 | 38 | - run: pnpm install --frozen-lockfile 39 | 40 | - run: pnpm compile:cli 41 | 42 | - name: Upload Artifacts 43 | uses: softprops/action-gh-release@v2 44 | if: startsWith(github.ref, 'refs/tags/') 45 | with: 46 | files: | 47 | cli/out/deemix-cli-* 48 | -------------------------------------------------------------------------------- /.github/workflows/build-release-electron.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Electron GUI 2 | 3 | on: 4 | push: 5 | tags: 6 | - deemix-gui@* 7 | pull_request: 8 | branches: 9 | - main 10 | paths: 11 | - gui/CHANGELOG.md 12 | - .github/workflows/build-release-electron.yml 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | env: 19 | HUSKY: 0 20 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 21 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 22 | 23 | jobs: 24 | build-release-electron: 25 | runs-on: ${{ matrix.os }} 26 | 27 | permissions: 28 | contents: write 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | os: [macos-latest, ubuntu-latest, windows-latest] 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: pnpm/action-setup@v4 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | cache: pnpm 42 | 43 | - name: Set up Python 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: "3.x" 47 | 48 | - name: Install distutils 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install setuptools 52 | 53 | - name: Set PYTHON env var 54 | run: echo "PYTHON=$(which python)" >> $GITHUB_ENV 55 | 56 | - run: pnpm install --frozen-lockfile 57 | - run: pnpm --filter=deemix-gui exec pnpm electron-rebuild 58 | 59 | - run: pnpm make 60 | 61 | - name: Upload Artifacts 62 | uses: softprops/action-gh-release@v2 63 | if: startsWith(github.ref, 'refs/tags/') 64 | with: 65 | files: | 66 | gui/out/**/Deemix.exe 67 | gui/out/**/Deemix*Setup.exe 68 | gui/out/make/zip/**/*.zip 69 | gui/out/make/deb/**/*.deb 70 | -------------------------------------------------------------------------------- /.github/workflows/create-release-or-publish.yml: -------------------------------------------------------------------------------- 1 | name: Create Release or Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | env: 11 | HUSKY: 0 12 | 13 | jobs: 14 | create-release-or-publish: 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: write 19 | pull-requests: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | ssh-key: ${{ secrets.DEPLOY_KEY }} 25 | - uses: pnpm/action-setup@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: pnpm 30 | - run: pnpm install --frozen-lockfile 31 | 32 | - name: Create Release Pull Request or Publish 33 | id: changesets 34 | uses: changesets/action@v1 35 | with: 36 | title: "Version Packages" 37 | commit: "chore(release): version packages" 38 | publish: pnpm changeset publish 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Lint, Test and Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | env: 9 | HUSKY: 0 10 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 11 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 12 | 13 | jobs: 14 | pull-request: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: pnpm 23 | 24 | - run: pnpm install --frozen-lockfile 25 | 26 | - run: pnpm run ci 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Dependency directories 7 | node_modules/ 8 | 9 | # Output of 'npm pack' 10 | *.tgz 11 | 12 | # dotenv environment variables file 13 | .env 14 | 15 | # IDE 16 | .vscode 17 | .idea 18 | 19 | # development 20 | *.map 21 | dev.sh 22 | 23 | # distribution 24 | dist/ 25 | release/ 26 | out/ 27 | 28 | .DS_Store 29 | webui/public/js/* 30 | 31 | .turbo 32 | tsconfig.tsbuildinfo 33 | vite.config.*.timestamp-* 34 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS base 2 | 3 | ARG TURBO_TEAM 4 | ENV TURBO_TEAM=$TURBO_TEAM 5 | 6 | RUN --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN 7 | 8 | ENV PNPM_HOME="/pnpm" 9 | ENV PATH="$PNPM_HOME:$PATH" 10 | RUN corepack enable 11 | 12 | WORKDIR /app 13 | 14 | COPY pnpm-lock.yaml . 15 | 16 | FROM base AS builder 17 | 18 | RUN pnpm install -g turbo 19 | 20 | COPY . . 21 | 22 | RUN turbo prune deemix-webui --docker 23 | 24 | FROM base AS installer 25 | 26 | COPY --from=builder /app/out/json/ . 27 | 28 | RUN apk add --no-cache python3 make g++ 29 | 30 | RUN pnpm install --frozen-lockfile 31 | 32 | COPY --from=builder /app/out/full/ . 33 | 34 | RUN pnpm turbo build --filter=deemix-webui... 35 | 36 | FROM ghcr.io/linuxserver/baseimage-alpine:3.20 AS runner 37 | 38 | RUN apk add --no-cache nodejs=~20 39 | 40 | COPY --from=installer /app /app 41 | 42 | COPY docker/ / 43 | 44 | ENV DEEMIX_DATA_DIR=/config/ 45 | ENV DEEMIX_MUSIC_DIR=/downloads/ 46 | ENV DEEMIX_SERVER_PORT=6595 47 | ENV DEEMIX_HOST=0.0.0.0 48 | ENV NODE_ENV=production 49 | 50 | EXPOSE $DEEMIX_SERVER_PORT 51 | ENTRYPOINT [ "/init" ] 52 | -------------------------------------------------------------------------------- /cli/.gitignore: -------------------------------------------------------------------------------- 1 | config/ 2 | -------------------------------------------------------------------------------- /cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # deemix-cli 2 | 3 | ## 0.1.0 4 | 5 | ### Minor Changes 6 | 7 | - f120283: Correctly create portable config 8 | 9 | ## 0.0.2 10 | 11 | ### Patch Changes 12 | 13 | - 78cdd4d: Fix error with missing asset causing crash 14 | 15 | ## 0.0.1 16 | 17 | ### Patch Changes 18 | 19 | - be606cc: Initial version 20 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Usage: deemix [options] 3 | 4 | A CLI wrapper for deemix 5 | 6 | Arguments: 7 | url The URL of the track or playlist 8 | 9 | Options: 10 | -V, --version output the version number 11 | -p, --path Downloads in the given folder 12 | -b, --bitrate Overrides the default bitrate selected 13 | --portable Creates the config folder in the same directory where the script is launched 14 | -h, --help display help for command 15 | ``` 16 | -------------------------------------------------------------------------------- /cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deemix-cli", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "A CLI wrapper for deemix", 6 | "main": "dist/main.cjs", 7 | "type": "module", 8 | "bin": { 9 | "deemix": "dist/main.cjs" 10 | }, 11 | "scripts": { 12 | "start": "tsx src/main.ts", 13 | "build": "tsup src/main.ts --format cjs", 14 | "compile": "pkg . --out-path out", 15 | "test": "vitest run", 16 | "lint": "eslint .", 17 | "type-check": "tsc --noEmit" 18 | }, 19 | "pkg": { 20 | "targets": [ 21 | "node20-macos-x64", 22 | "node20-macos-arm64", 23 | "node20-linux-x64", 24 | "node20-linux-arm64", 25 | "node20-win-x64", 26 | "node20-win-arm64" 27 | ] 28 | }, 29 | "devDependencies": { 30 | "@yao-pkg/pkg": "^5.15.0", 31 | "commander": "^12.1.0", 32 | "deemix": "workspace:*", 33 | "deezer-sdk": "workspace:*", 34 | "tsup": "^8.2.4", 35 | "tsx": "^4.19.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cli/src/bitrate.test.ts: -------------------------------------------------------------------------------- 1 | import { TrackFormats } from "deezer-sdk"; 2 | import { parseBitrate } from "./bitrate"; 3 | 4 | const bitrateInputExpectedMap = [ 5 | ["1", TrackFormats.MP3_128], 6 | ["mp3_128", TrackFormats.MP3_128], 7 | ["128", TrackFormats.MP3_128], 8 | 9 | ["3", TrackFormats.MP3_320], 10 | ["mp3_320", TrackFormats.MP3_320], 11 | ["mp3", TrackFormats.MP3_320], 12 | ["320", TrackFormats.MP3_320], 13 | 14 | ["9", TrackFormats.FLAC], 15 | ["flac", TrackFormats.FLAC], 16 | ["lossless", TrackFormats.FLAC], 17 | 18 | ["13", TrackFormats.MP4_RA1], 19 | ["mp4_ra1", TrackFormats.MP4_RA1], 20 | ["360_lq", TrackFormats.MP4_RA1], 21 | 22 | ["14", TrackFormats.MP4_RA2], 23 | ["mp4_ra2", TrackFormats.MP4_RA2], 24 | ["360_mq", TrackFormats.MP4_RA2], 25 | 26 | ["15", TrackFormats.MP4_RA3], 27 | ["mp4_ra3", TrackFormats.MP4_RA3], 28 | ["360", TrackFormats.MP4_RA3], 29 | ["360_hq", TrackFormats.MP4_RA3], 30 | ] as const; 31 | 32 | const mockExit = vi.spyOn(process, "exit").mockImplementation((number) => { 33 | throw new Error(`Exit with code ${number}`); 34 | }); 35 | 36 | test.each(bitrateInputExpectedMap)( 37 | "parseBitrate with no default - %s -> %i", 38 | (input: string, expected: number) => { 39 | const settings = { maxBitrate: undefined }; 40 | 41 | parseBitrate(settings, input); 42 | 43 | expect(settings.maxBitrate).toBe(expected); 44 | } 45 | ); 46 | 47 | test.each(bitrateInputExpectedMap)( 48 | "parseBitrate with FLAC default - %s -> %i", 49 | (input: string, expected: number) => { 50 | const settings = { maxBitrate: TrackFormats.FLAC }; 51 | 52 | parseBitrate(settings, input); 53 | 54 | expect(settings.maxBitrate).toBe(expected); 55 | } 56 | ); 57 | 58 | const invalidBitrates = ["invalid", "invalid123", "123", "1.0"]; 59 | 60 | test.each(invalidBitrates)("invalid bitrate - %s", (bitrate: string) => { 61 | const settings = { maxBitrate: TrackFormats.MP3_128 }; 62 | 63 | expect(() => parseBitrate(settings, bitrate)).toThrowError(); 64 | 65 | expect(mockExit).toHaveBeenCalledWith(1); 66 | }); 67 | -------------------------------------------------------------------------------- /cli/src/bitrate.ts: -------------------------------------------------------------------------------- 1 | import type { Settings } from "deemix"; 2 | import { TrackFormats } from "deezer-sdk"; 3 | 4 | const bitrateTextNumberMap = { 5 | [TrackFormats.MP3_128]: ["mp3_128", "128", "1"], 6 | [TrackFormats.MP3_320]: ["mp3_320", "mp3", "320", "3"], 7 | [TrackFormats.FLAC]: ["flac", "flac", "lossless", "9"], 8 | [TrackFormats.MP4_RA1]: ["mp4_ra1", "360_lq", "13"], 9 | [TrackFormats.MP4_RA2]: ["mp4_ra2", "360_mq", "14"], 10 | [TrackFormats.MP4_RA3]: ["mp4_ra3", "360", "360_hq", "15"], 11 | } as const; 12 | 13 | const displayBitrateHelp = (inputBitrate: string) => { 14 | console.log(`Invalid bitrate:\n- ${inputBitrate}\n`); 15 | 16 | console.log("Available bitrates:"); 17 | 18 | for (const [bitrateName, bitrateNumber] of Object.entries(TrackFormats)) { 19 | if ( 20 | bitrateNumber === TrackFormats.LOCAL || 21 | bitrateNumber === TrackFormats.DEFAULT 22 | ) 23 | continue; 24 | 25 | console.log( 26 | `- ${bitrateName}: ${bitrateTextNumberMap[bitrateNumber].slice(1).join(", ")}` 27 | ); 28 | } 29 | 30 | console.log(""); 31 | }; 32 | 33 | export function parseBitrate( 34 | settings: Pick, 35 | bitrate: string 36 | ) { 37 | const downloadBitrate = getBitrateNumberFromText(bitrate); 38 | 39 | if (downloadBitrate) { 40 | settings.maxBitrate = downloadBitrate; 41 | } else { 42 | displayBitrateHelp(bitrate); 43 | 44 | process.exit(1); 45 | } 46 | } 47 | 48 | export function getBitrateNumberFromText(text: string) { 49 | text = text.trim().toLowerCase(); 50 | 51 | for (const [bitrate, bitrateTexts] of Object.entries(bitrateTextNumberMap)) { 52 | if ((bitrateTexts as readonly string[]).includes(text)) 53 | return Number(bitrate); 54 | } 55 | 56 | return undefined; 57 | } 58 | -------------------------------------------------------------------------------- /cli/src/download.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Convertable, 3 | Downloader, 4 | generateDownloadObject, 5 | SpotifyPlugin, 6 | utils, 7 | type Listener, 8 | type Settings, 9 | } from "deemix"; 10 | import { TrackFormats, type Deezer } from "deezer-sdk"; 11 | import { configFolder } from "./main"; 12 | 13 | const listener: Listener = { 14 | send: (key: string, data?: unknown) => { 15 | const logLine = utils.formatListener(key, data); 16 | if (logLine) console.log(logLine); 17 | if (["downloadInfo", "downloadWarn"].includes(key)) return; 18 | }, 19 | }; 20 | 21 | export const downloadLinks = async ( 22 | dz: Deezer, 23 | urls: string[], 24 | settings: Settings, 25 | spotifyPlugin: SpotifyPlugin 26 | ) => { 27 | const bitrate = settings.maxBitrate ?? TrackFormats.MP3_128; 28 | 29 | const downloadObjects = []; 30 | for (const url of urls) { 31 | try { 32 | const downloadObject = await generateDownloadObject( 33 | dz, 34 | url, 35 | bitrate, 36 | { spotify: spotifyPlugin }, 37 | listener 38 | ); 39 | 40 | if (Array.isArray(downloadObject)) { 41 | downloadObjects.concat(downloadObject); 42 | } else { 43 | downloadObjects.push(downloadObject); 44 | } 45 | } catch (e) { 46 | if (e instanceof Error && e.name === "PluginNotEnabledError") { 47 | console.warn( 48 | `Populate Spotify app credentials to download Spotify links:\n\t${configFolder}spotify/config.json\n` 49 | ); 50 | console.log( 51 | "Documentation: https://developer.spotify.com/documentation/web-api/tutorials/getting-started#create-an-app" 52 | ); 53 | 54 | continue; 55 | } 56 | 57 | if (e instanceof Error) { 58 | console.error(e); 59 | } 60 | } 61 | } 62 | 63 | for (let downloadObject of downloadObjects) { 64 | if (downloadObject instanceof Convertable) { 65 | downloadObject = await spotifyPlugin.convert( 66 | dz, 67 | downloadObject, 68 | settings, 69 | listener 70 | ); 71 | } 72 | 73 | const downloader = new Downloader(dz, downloadObject, settings, listener); 74 | await downloader.start(); 75 | } 76 | 77 | return "Done"; 78 | }; 79 | -------------------------------------------------------------------------------- /cli/src/login.ts: -------------------------------------------------------------------------------- 1 | import type { Deezer } from "deezer-sdk"; 2 | import { existsSync, readFileSync, rmSync, writeFileSync } from "fs"; 3 | import readline from "node:readline/promises"; 4 | import path from "path"; 5 | 6 | export const deezerLogin = async (dz: Deezer, configFolder: string) => { 7 | const arlFileLocation = path.join(configFolder, ".arl"); 8 | 9 | if (existsSync(arlFileLocation)) { 10 | const arl = readFileSync(arlFileLocation).toString().trim(); 11 | const loggedIn = await dz.loginViaArl(arl); 12 | 13 | if (loggedIn) { 14 | return true; 15 | } 16 | 17 | // If the ARL is invalid, remove it 18 | rmSync(arlFileLocation); 19 | } 20 | 21 | const rl = readline.createInterface({ 22 | input: process.stdin, 23 | output: process.stdout, 24 | }); 25 | const arl = await rl.question("Enter your ARL: "); 26 | await dz.loginViaArl(arl); 27 | writeFileSync(arlFileLocation, arl); 28 | 29 | return dz.loggedIn; 30 | }; 31 | -------------------------------------------------------------------------------- /cli/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { loadSettings, SpotifyPlugin, utils } from "deemix"; 3 | import { Deezer, setDeezerCacheDir } from "deezer-sdk"; 4 | import path, { sep } from "path"; 5 | import packageJson from "../package.json" with { type: "json" }; 6 | import { parseBitrate } from "./bitrate"; 7 | import { downloadLinks } from "./download"; 8 | import { deezerLogin } from "./login"; 9 | 10 | const { getConfigFolder } = utils; 11 | 12 | const program = new Command(); 13 | 14 | program 15 | .name("deemix") 16 | .description("A CLI wrapper for deemix") 17 | .version(packageJson.version) 18 | .argument("", "The URL of the track or playlist") 19 | .option("-p, --path ", "Downloads in the given folder") 20 | .option( 21 | "-b, --bitrate ", 22 | "Overrides the default bitrate selected - 128, 320, flac" 23 | ) 24 | .option( 25 | "--portable", 26 | "Creates the config folder in the same directory where the script is launched" 27 | ) 28 | .parse(); 29 | 30 | const { portable } = program.opts(); 31 | 32 | export let configFolder: string; 33 | 34 | if (portable) { 35 | configFolder = path.join(process.cwd(), `config${sep}`); 36 | } else { 37 | configFolder = getConfigFolder(); 38 | } 39 | setDeezerCacheDir(configFolder); 40 | 41 | const settings = loadSettings(configFolder); 42 | 43 | const spotifyPlugin = new SpotifyPlugin(configFolder); 44 | spotifyPlugin.setup(); 45 | 46 | const { path: downloadPath, bitrate } = program.opts(); 47 | 48 | if (downloadPath) settings.downloadLocation = path.resolve(downloadPath); 49 | if (bitrate) parseBitrate(settings, bitrate); 50 | 51 | const loginAndDownload = async () => { 52 | const dz = new Deezer(); 53 | const loggedIn = await deezerLogin(dz, configFolder); 54 | 55 | if (loggedIn) { 56 | const urls = program.args; 57 | 58 | await downloadLinks(dz, urls, settings, spotifyPlugin); 59 | 60 | process.exit(0); 61 | } else { 62 | console.error("Not logged in"); 63 | 64 | process.exit(1); 65 | } 66 | }; 67 | 68 | loginAndDownload(); 69 | -------------------------------------------------------------------------------- /cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/bundler/no-dom/library-monorepo", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "types": ["vitest/globals"] 7 | }, 8 | "include": ["./src/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /cli/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["src/**/*.test.ts"], 6 | globals: true, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /deemix/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # deemix 2 | 3 | ## 3.13.2 4 | 5 | ### Patch Changes 6 | 7 | - 059678b: Fix itunes compilation flag not saving correctly 8 | - 5fc671e: Fix "keep both" download option adding number to first song 9 | 10 | ## 3.13.1 11 | 12 | ### Patch Changes 13 | 14 | - d4015d3: Fix title casing not being applied correctly 15 | 16 | ## 3.13.0 17 | 18 | ### Minor Changes 19 | 20 | - ec74308: Fix name generation for %playlist% replacement 21 | 22 | ## 3.12.2 23 | 24 | ### Patch Changes 25 | 26 | - Updated dependencies [9ea682c] 27 | - deezer-sdk@1.10.0 28 | 29 | ## 3.12.1 30 | 31 | ### Patch Changes 32 | 33 | - Updated dependencies [5f610aa] 34 | - deezer-sdk@1.9.0 35 | 36 | ## 3.12.0 37 | 38 | ### Minor Changes 39 | 40 | - 891f179: Fix blowfish library causing downloaded tracks to have inconsistent playback speed 41 | 42 | ### Patch Changes 43 | 44 | - 6d90884: Set album and file extension before generating filename 45 | 46 | ## 3.11.2 47 | 48 | ### Patch Changes 49 | 50 | - Updated dependencies [c464740] 51 | - deezer-sdk@1.8.1 52 | 53 | ## 3.11.1 54 | 55 | ### Patch Changes 56 | 57 | - Updated dependencies [4a38238] 58 | - Updated dependencies [0d08d68] 59 | - deezer-sdk@1.8.0 60 | 61 | ## 3.11.0 62 | 63 | ### Minor Changes 64 | 65 | - d6c0175: Ship package as compiled js 66 | 67 | ### Patch Changes 68 | 69 | - ca61198: Include type definitions with bundle 70 | - Updated dependencies [15e03de] 71 | - Updated dependencies [1453cc7] 72 | - deezer-sdk@1.7.0 73 | 74 | ## 3.10.0 75 | 76 | ### Minor Changes 77 | 78 | - a2e64c5: Significantly improved typing 79 | 80 | ### Patch Changes 81 | 82 | - Updated dependencies [a2e64c5] 83 | - deezer-sdk@1.6.0 84 | 85 | ## 3.9.0 86 | 87 | ### Minor Changes 88 | 89 | - 54f44ca: Significantly faster when tracks already downloaded 90 | 91 | ### Patch Changes 92 | 93 | - Updated dependencies [ad518d0] 94 | - deezer-sdk@1.5.0 95 | 96 | ## 3.8.0 97 | 98 | ### Minor Changes 99 | 100 | - 59b9efc: Monorepo Refactor 101 | -------------------------------------------------------------------------------- /deemix/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deemix", 3 | "version": "3.13.2", 4 | "private": true, 5 | "description": "A barebones deezer downloader library", 6 | "type": "module", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "dev": "run-p watch:*", 11 | "watch:build": "tsup src/index.ts --format esm --watch", 12 | "watch:tsc": "tsc --emitDeclarationOnly -w", 13 | "build": "run-s build:*", 14 | "build:tsup": "tsup src/index.ts --format esm", 15 | "build:tsc": "tsc --emitDeclarationOnly", 16 | "test": "vitest run", 17 | "test:watch": "vitest", 18 | "lint": "eslint .", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "author": "Bambanah", 22 | "license": "GPL-3.0-or-later", 23 | "dependencies": { 24 | "@spotify/web-api-ts-sdk": "^1.2.0", 25 | "async": "^3.2.0", 26 | "browser-id3-writer": "^6.2.0", 27 | "deezer-sdk": "workspace:*", 28 | "got": "^14.4.2", 29 | "html-entities": "^2.3.3", 30 | "metaflac-js2": "^1.0.8", 31 | "tough-cookie": "^4.0.0" 32 | }, 33 | "devDependencies": { 34 | "@types/async": "^3.2.24", 35 | "tsup": "^8.2.4", 36 | "vite-tsconfig-paths": "^5.0.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /deemix/src/download-objects/Collection.ts: -------------------------------------------------------------------------------- 1 | import type { Listener } from "@/types/listener.js"; 2 | import { DownloadObject } from "./DownloadObject.js"; 3 | import { type Track as SpotifyTrack } from "@spotify/web-api-ts-sdk"; 4 | 5 | export class Collection extends DownloadObject { 6 | collection: any; 7 | 8 | constructor(obj) { 9 | super(obj); 10 | this.collection = obj.collection; 11 | this.__type__ = "Collection"; 12 | } 13 | 14 | override toDict() { 15 | const item = super.toDict(); 16 | 17 | return { ...item, collection: this.collection }; 18 | } 19 | 20 | completeTrackProgress(listener: Listener) { 21 | this.progressNext += (1 / this.size) * 100; 22 | this.updateProgress(listener); 23 | } 24 | 25 | removeTrackProgress(listener: Listener) { 26 | this.progressNext -= (1 / this.size) * 100; 27 | this.updateProgress(listener); 28 | } 29 | } 30 | 31 | export class Convertable extends Collection { 32 | plugin: string; 33 | conversionData: SpotifyTrack[]; 34 | 35 | constructor(obj) { 36 | super(obj); 37 | this.plugin = obj.plugin; 38 | this.conversionData = obj.conversion_data; 39 | this.__type__ = "Convertable"; 40 | } 41 | 42 | override toDict() { 43 | const item = super.toDict(); 44 | 45 | return { 46 | ...item, 47 | plugin: this.plugin, 48 | conversion_data: this.conversionData, 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /deemix/src/download-objects/Single.ts: -------------------------------------------------------------------------------- 1 | import type { DeezerTrack } from "deezer-sdk"; 2 | import { DownloadObject } from "./DownloadObject.js"; 3 | 4 | export class Single extends DownloadObject { 5 | single: DeezerTrack; 6 | 7 | constructor({ 8 | single, 9 | ...rest 10 | }: { single: DeezerTrack } & Partial) { 11 | super(rest); 12 | this.size = 1; 13 | this.single = single; 14 | this.__type__ = "Single"; 15 | } 16 | 17 | override toDict() { 18 | const item = super.toDict(); 19 | 20 | return { ...item, single: this.single }; 21 | } 22 | 23 | completeTrackProgress(listener) { 24 | this.progressNext = 100; 25 | this.updateProgress(listener); 26 | } 27 | 28 | removeTrackProgress(listener) { 29 | this.progressNext = 0; 30 | this.updateProgress(listener); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /deemix/src/download-objects/generateArtistTopItem.ts: -------------------------------------------------------------------------------- 1 | import { Deezer, type APIArtist } from "deezer-sdk"; 2 | import { GenerationError, InvalidID } from "../errors.js"; 3 | import { generatePlaylistItem } from "./generatePlaylistItem.js"; 4 | 5 | export async function generateArtistTopItem( 6 | dz: Deezer, 7 | id: string, 8 | bitrate: number 9 | ) { 10 | if (!/^\d+$/.test(id)) 11 | throw new InvalidID(`https://deezer.com/artist/${id}/top_track`); 12 | // Get essential artist info 13 | let artistAPI: Partial; 14 | try { 15 | artistAPI = await dz.api.get_artist(id); 16 | } catch (e) { 17 | console.trace(e); 18 | throw new GenerationError( 19 | `https://deezer.com/artist/${id}/top_track`, 20 | e.message 21 | ); 22 | } 23 | 24 | // Emulate the creation of a playlist 25 | // Can't use generatePlaylistItem directly as this is not a real playlist 26 | const playlistAPI = { 27 | id: artistAPI.id + "_top_track", 28 | title: artistAPI.name + " - Top Tracks", 29 | description: "Top Tracks for " + artistAPI.name, 30 | duration: 0, 31 | public: true, 32 | is_loved_track: false, 33 | collaborative: false, 34 | nb_tracks: 0, 35 | fans: artistAPI.nb_fan, 36 | link: "https://www.deezer.com/artist/" + artistAPI.id + "/top_track", 37 | share: null, 38 | picture: artistAPI.picture, 39 | picture_small: artistAPI.picture_small, 40 | picture_medium: artistAPI.picture_medium, 41 | picture_big: artistAPI.picture_big, 42 | picture_xl: artistAPI.picture_xl, 43 | checksum: null, 44 | tracklist: "https://api.deezer.com/artist/" + artistAPI.id + "/top", 45 | creation_date: "XXXX-00-00", 46 | creator: { 47 | id: "art_" + artistAPI.id, 48 | name: artistAPI.name, 49 | type: "user", 50 | }, 51 | type: "playlist", 52 | }; 53 | 54 | const artistTopTracksAPI_gw = await dz.gw.get_artist_top_tracks(id); 55 | return generatePlaylistItem( 56 | dz, 57 | playlistAPI.id, 58 | bitrate, 59 | playlistAPI, 60 | artistTopTracksAPI_gw 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /deemix/src/download-objects/generateTrackItem.ts: -------------------------------------------------------------------------------- 1 | import { Deezer, utils, type APIAlbum, type DeezerTrack } from "deezer-sdk"; 2 | import { GenerationError, ISRCnotOnDeezer, InvalidID } from "@/errors.js"; 3 | import { Single } from "./Single.js"; 4 | 5 | const { mapGwTrackToDeezer } = utils; 6 | 7 | export async function generateTrackItem( 8 | dz: Deezer, 9 | id: number | string, 10 | bitrate: number, 11 | albumAPI?: APIAlbum 12 | ) { 13 | let deezerTrack: DeezerTrack; 14 | 15 | // Get essential track info 16 | if (String(id).startsWith("isrc") || (typeof id === "number" && id > 0)) { 17 | try { 18 | deezerTrack = await dz.api.getTrack(id); 19 | } catch (e) { 20 | throw new GenerationError(`https://deezer.com/track/${id}`, e.message); 21 | } 22 | 23 | // Check if is an isrc: url 24 | if (String(id).startsWith("isrc")) { 25 | if (deezerTrack.id && deezerTrack.title) { 26 | id = deezerTrack.id; 27 | } else { 28 | throw new ISRCnotOnDeezer(`https://deezer.com/track/${id}`); 29 | } 30 | } 31 | } else { 32 | const gwTrack = await dz.gw.getTrack(id); 33 | deezerTrack = mapGwTrackToDeezer(gwTrack); 34 | } 35 | 36 | if (!/^-?\d+$/.test(String(id))) 37 | throw new InvalidID(`https://deezer.com/track/${id}`); 38 | 39 | let cover: string; 40 | if (deezerTrack.album.cover_small) { 41 | cover = 42 | deezerTrack.album.cover_small.slice(0, -24) + "/75x75-000000-80-0-0.jpg"; 43 | } else { 44 | cover = `https://e-cdns-images.dzcdn.net/images/cover/${deezerTrack.md5_image}/75x75-000000-80-0-0.jpg`; 45 | } 46 | 47 | delete deezerTrack.track_token; 48 | 49 | return new Single({ 50 | type: "track", 51 | id, 52 | bitrate, 53 | title: deezerTrack.title, 54 | artist: deezerTrack.artist.name, 55 | cover, 56 | explicit: deezerTrack.explicit_lyrics, 57 | single: { 58 | trackAPI: deezerTrack, 59 | albumAPI, 60 | }, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /deemix/src/download-objects/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Collection.js"; 2 | export * from "./DownloadObject.js"; 3 | export * from "./Single.js"; 4 | 5 | export * from "./generateAlbumItem.js"; 6 | export * from "./generateArtistTopItem.js"; 7 | export * from "./generatePlaylistItem.js"; 8 | export * from "./generateTrackItem.js"; 9 | -------------------------------------------------------------------------------- /deemix/src/plugins/base.ts: -------------------------------------------------------------------------------- 1 | export default class BasePlugin { 2 | /* constructor () {} */ 3 | setup() { 4 | return this; 5 | } 6 | 7 | async parseLink(link: string) { 8 | return [link, undefined, undefined]; 9 | } 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | async generateDownloadObject(dz, link, bitrate, listener) { 13 | return null; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /deemix/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SpotifyPlugin } from "./spotify.js"; 2 | -------------------------------------------------------------------------------- /deemix/src/types/Artist.ts: -------------------------------------------------------------------------------- 1 | import { Picture } from "./Picture.js"; 2 | import { VARIOUS_ARTISTS } from "./index.js"; 3 | 4 | export class Artist { 5 | id: number; 6 | name: string; 7 | pic: Picture; 8 | role: string; 9 | save: boolean; 10 | 11 | constructor(art_id: number = 0, name = "", role = "", pic_md5 = "") { 12 | this.id = art_id; 13 | this.name = name; 14 | this.pic = new Picture(pic_md5, "artist"); 15 | this.role = role; 16 | this.save = true; 17 | } 18 | 19 | isVariousArtists() { 20 | return this.id === VARIOUS_ARTISTS; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /deemix/src/types/CustomDate.ts: -------------------------------------------------------------------------------- 1 | export class CustomDate { 2 | day: string; 3 | month: string; 4 | year: string; 5 | 6 | constructor(day = "00", month = "00", year = "XXXX") { 7 | this.day = day; 8 | this.month = month; 9 | this.year = year; 10 | this.fixDayMonth(); 11 | } 12 | 13 | fixDayMonth() { 14 | if (parseInt(this.month) > 12) { 15 | const monthTemp = this.month; 16 | this.month = this.day; 17 | this.day = monthTemp; 18 | } 19 | } 20 | 21 | format(template: string) { 22 | template = template.replaceAll(/D+/g, this.day); 23 | template = template.replaceAll(/M+/g, this.month); 24 | template = template.replaceAll(/Y+/g, this.year); 25 | return template; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /deemix/src/types/Lyrics.ts: -------------------------------------------------------------------------------- 1 | import { decode } from "html-entities"; 2 | 3 | export class Lyrics { 4 | id: string; 5 | sync: string; 6 | unsync: string; 7 | syncID3: any[]; 8 | 9 | constructor(lyr_id = "0") { 10 | this.id = lyr_id; 11 | this.sync = ""; 12 | this.unsync = ""; 13 | this.syncID3 = []; 14 | } 15 | 16 | parseLyrics(lyricsAPI) { 17 | this.unsync = lyricsAPI.LYRICS_TEXT || ""; 18 | if (lyricsAPI.LYRICS_SYNC_JSON) { 19 | const syncLyricsJson = lyricsAPI.LYRICS_SYNC_JSON; 20 | let timestamp = ""; 21 | let milliseconds = 0; 22 | for (let line = 0; line < syncLyricsJson.length; line++) { 23 | const currentLine = decode(syncLyricsJson[line].line); 24 | if (currentLine !== "") { 25 | timestamp = syncLyricsJson[line].lrc_timestamp; 26 | milliseconds = parseInt(syncLyricsJson[line].milliseconds); 27 | this.syncID3.push([currentLine, milliseconds]); 28 | } else { 29 | let notEmptyLine = line + 1; 30 | while (syncLyricsJson[notEmptyLine].line === "") notEmptyLine += 1; 31 | timestamp = syncLyricsJson[notEmptyLine].lrc_timestamp; 32 | } 33 | this.sync += timestamp + currentLine + "\r\n"; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /deemix/src/types/Picture.ts: -------------------------------------------------------------------------------- 1 | export class Picture { 2 | md5: string; 3 | type: string; 4 | 5 | constructor(md5 = "", pic_type = "") { 6 | this.md5 = md5; 7 | this.type = pic_type; 8 | } 9 | 10 | getURL(size, format) { 11 | const url = `https://e-cdns-images.dzcdn.net/images/${this.type}/${this.md5}/${size}x${size}`; 12 | 13 | if (format.startsWith("jpg")) { 14 | let quality = 80; 15 | if (format.includes("-")) quality = parseInt(format.substr(4)); 16 | format = "jpg"; 17 | return url + `-000000-${quality}-0-0.jpg`; 18 | } 19 | if (format === "png") { 20 | return url + "-none-100-0-0.png"; 21 | } 22 | 23 | return url + ".jpg"; 24 | } 25 | } 26 | 27 | export class StaticPicture { 28 | staticURL: string; 29 | 30 | constructor(url: string) { 31 | this.staticURL = url; 32 | } 33 | 34 | getURL() { 35 | return this.staticURL; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /deemix/src/types/Playlist.ts: -------------------------------------------------------------------------------- 1 | import { Artist } from "./Artist.js"; 2 | import { CustomDate } from "./CustomDate.js"; 3 | import { Picture, StaticPicture } from "./Picture.js"; 4 | 5 | export class Playlist { 6 | id: string; 7 | title: any; 8 | artist: { Main: any[] }; 9 | artists: any[]; 10 | trackTotal: any; 11 | recordType: string; 12 | barcode: string; 13 | label: string; 14 | explicit: any; 15 | genre: string[]; 16 | date: CustomDate; 17 | dateString?: string; 18 | discTotal: string; 19 | playlistID: any; 20 | owner: any; 21 | pic: Picture | StaticPicture; 22 | variousArtists: Artist; 23 | mainArtist: any; 24 | bitrate?: number; 25 | 26 | constructor(playlistAPI) { 27 | this.id = `pl_${playlistAPI.id}`; 28 | this.title = playlistAPI.title; 29 | this.artist = { Main: [] }; 30 | this.artists = []; 31 | this.trackTotal = playlistAPI.nb_tracks; 32 | this.recordType = "compile"; 33 | this.barcode = ""; 34 | this.label = ""; 35 | this.explicit = playlistAPI.explicit; 36 | this.genre = ["Compilation"]; 37 | 38 | const year = playlistAPI.creation_date.slice(0, 4); 39 | const month = playlistAPI.creation_date.slice(5, 7); 40 | const day = playlistAPI.creation_date.slice(8, 10); 41 | this.date = new CustomDate(day, month, year); 42 | 43 | this.discTotal = "1"; 44 | this.playlistID = playlistAPI.id; 45 | this.owner = playlistAPI.creator; 46 | 47 | if (playlistAPI.picture_small.includes("dzcdn.net")) { 48 | const url = playlistAPI.picture_small; 49 | let picType = url.slice(url.indexOf("images/") + 7); 50 | picType = picType.slice(0, picType.indexOf("/")); 51 | const md5 = url.slice( 52 | url.indexOf(picType + "/") + picType.length + 1, 53 | -24 54 | ); 55 | this.pic = new Picture(md5, picType); 56 | } else { 57 | this.pic = new StaticPicture(playlistAPI.picture_xl); 58 | } 59 | 60 | if (playlistAPI.various_artist) { 61 | let pic_md5 = playlistAPI.various_artist.picture_small; 62 | pic_md5 = pic_md5.slice(pic_md5.indexOf("artist/") + 7, -24); 63 | this.variousArtists = new Artist( 64 | playlistAPI.various_artist.id, 65 | playlistAPI.various_artist.name, 66 | playlistAPI.various_artist.role, 67 | pic_md5 68 | ); 69 | this.mainArtist = this.variousArtists; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /deemix/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export const VARIOUS_ARTISTS = 5080; 2 | 3 | export * from "./Album.js"; 4 | export * from "./Artist.js"; 5 | export * from "./CustomDate.js"; 6 | export * from "./listener.js"; 7 | export * from "./Lyrics.js"; 8 | export * from "./Picture.js"; 9 | export * from "./Playlist.js"; 10 | export * from "./Settings.js"; 11 | export * from "./Track.js"; 12 | -------------------------------------------------------------------------------- /deemix/src/types/listener.ts: -------------------------------------------------------------------------------- 1 | export interface Listener { 2 | send: (key: string, data?: any) => void; 3 | } 4 | -------------------------------------------------------------------------------- /deemix/src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createCipheriv, 3 | createHash, 4 | createDecipheriv, 5 | getCiphers, 6 | } from "crypto"; 7 | 8 | let Blowfish; 9 | 10 | try { 11 | // eslint-disable-next-line @typescript-eslint/no-require-imports 12 | Blowfish = require("./blowfish.cjs"); 13 | } catch (e) { 14 | console.error(e); 15 | } 16 | 17 | export function _md5(data, type: BufferEncoding = "binary") { 18 | const md5sum = createHash("md5"); 19 | md5sum.update(Buffer.from(data, type)); 20 | return md5sum.digest("hex"); 21 | } 22 | 23 | export function _ecbCrypt(key, data) { 24 | const cipher = createCipheriv( 25 | "aes-128-ecb", 26 | Buffer.from(key), 27 | Buffer.from("") 28 | ); 29 | cipher.setAutoPadding(false); 30 | return Buffer.concat([cipher.update(data, "binary"), cipher.final()]) 31 | .toString("hex") 32 | .toLowerCase(); 33 | } 34 | 35 | export function _ecbDecrypt(key, data) { 36 | const cipher = createDecipheriv( 37 | "aes-128-ecb", 38 | Buffer.from(key), 39 | Buffer.from("") 40 | ); 41 | cipher.setAutoPadding(false); 42 | return Buffer.concat([cipher.update(data, "binary"), cipher.final()]) 43 | .toString("hex") 44 | .toLowerCase(); 45 | } 46 | 47 | export function generateBlowfishKey(trackId) { 48 | const SECRET = "g4el58wc0zvf9na1"; 49 | const idMd5 = _md5(trackId.toString(), "ascii"); 50 | let bfKey = ""; 51 | for (let i = 0; i < 16; i++) { 52 | bfKey += String.fromCharCode( 53 | idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i) 54 | ); 55 | } 56 | return String(bfKey); 57 | } 58 | 59 | export function decryptChunk(chunk, blowFishKey) { 60 | const ciphers = getCiphers(); 61 | if (ciphers.includes("bf-cbc")) { 62 | const cipher = createDecipheriv( 63 | "bf-cbc", 64 | blowFishKey, 65 | Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]) 66 | ); 67 | cipher.setAutoPadding(false); 68 | return Buffer.concat([cipher.update(chunk), cipher.final()]); 69 | } 70 | if (Blowfish) { 71 | const cipher = new Blowfish( 72 | blowFishKey, 73 | Blowfish.MODE.CBC, 74 | Blowfish.PADDING.NULL 75 | ); 76 | cipher.setIv(Buffer.from([0, 1, 2, 3, 4, 5, 6, 7])); 77 | return Buffer.from(cipher.decode(chunk, Blowfish.TYPE.UINT8_ARRAY)); 78 | } 79 | throw new Error("Can't find a way to decrypt chunks"); 80 | } 81 | -------------------------------------------------------------------------------- /deemix/src/utils/deezer.ts: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | import { CookieJar } from "tough-cookie"; 3 | import { USER_AGENT_HEADER } from "./index.js"; 4 | import { _md5 } from "./crypto.js"; 5 | 6 | const CLIENT_ID = "172365"; 7 | const CLIENT_SECRET = "fb0bec7ccc063dab0417eb7b0d847f34"; 8 | 9 | export async function getDeezerAccessTokenFromEmailPassword(email, password) { 10 | let accessToken = null; 11 | password = _md5(password, "utf8"); 12 | const hash = _md5( 13 | [CLIENT_ID, email, password, CLIENT_SECRET].join(""), 14 | "utf8" 15 | ); 16 | try { 17 | const response = got.get("https://api.deezer.com/auth/token", { 18 | searchParams: { 19 | app_id: CLIENT_ID, 20 | login: email, 21 | password, 22 | hash, 23 | }, 24 | https: { rejectUnauthorized: false }, 25 | headers: { "User-Agent": USER_AGENT_HEADER }, 26 | }); 27 | const responseData = (await response.json()) as { access_token?: string }; 28 | 29 | accessToken = responseData.access_token ?? null; 30 | } catch { 31 | /* empty */ 32 | } 33 | return accessToken; 34 | } 35 | 36 | export async function getDeezerArlFromAccessToken(accessToken) { 37 | if (!accessToken) return null; 38 | let arl = null; 39 | const cookieJar = new CookieJar(); 40 | try { 41 | await got.get("https://api.deezer.com/platform/generic/track/3135556", { 42 | headers: { 43 | Authorization: `Bearer ${accessToken}`, 44 | "User-Agent": USER_AGENT_HEADER, 45 | }, 46 | https: { rejectUnauthorized: false }, 47 | cookieJar, 48 | }); 49 | const response = got.get( 50 | "https://www.deezer.com/ajax/gw-light.php?method=user.getArl&input=3&api_version=1.0&api_token=null", 51 | { 52 | headers: { "User-Agent": USER_AGENT_HEADER }, 53 | https: { rejectUnauthorized: false }, 54 | cookieJar, 55 | } 56 | ); 57 | const responseData = (await response.json()) as { results?: string }; 58 | 59 | arl = responseData.results ?? null; 60 | } catch { 61 | /* empty */ 62 | } 63 | return arl; 64 | } 65 | -------------------------------------------------------------------------------- /deemix/src/utils/downloadUtils.ts: -------------------------------------------------------------------------------- 1 | import type { Tags } from "@/types/Settings.js"; 2 | import type Track from "@/types/Track.js"; 3 | import fs from "fs"; 4 | import { OverwriteOption } from "../settings.js"; 5 | import { tagFLAC, tagID3 } from "../tagger.js"; 6 | 7 | export const checkShouldDownload = ( 8 | filename: string, 9 | filepath: string, 10 | extension: string, 11 | writepath: string, 12 | overwriteFile: string, 13 | track: Track 14 | ) => { 15 | if ( 16 | overwriteFile === OverwriteOption.OVERWRITE || 17 | overwriteFile === OverwriteOption.KEEP_BOTH 18 | ) 19 | return true; 20 | 21 | const trackAlreadyDownloaded = fs.existsSync(writepath); 22 | 23 | if ( 24 | trackAlreadyDownloaded && 25 | overwriteFile === OverwriteOption.DONT_OVERWRITE 26 | ) 27 | return false; 28 | 29 | // Don't overwrite and don't mind extension 30 | if ( 31 | !trackAlreadyDownloaded && 32 | overwriteFile === OverwriteOption.DONT_CHECK_EXT 33 | ) { 34 | const extensions = [".mp3", ".flac", ".opus", ".m4a"]; 35 | const baseFilename = `${filepath}/${filename}`; 36 | 37 | for (const ext of extensions) { 38 | if (fs.existsSync(baseFilename + ext)) return false; 39 | } 40 | } 41 | 42 | // Overwrite only lower bitrates 43 | if ( 44 | trackAlreadyDownloaded && 45 | overwriteFile === OverwriteOption.ONLY_LOWER_BITRATES && 46 | extension === ".mp3" 47 | ) { 48 | const stats = fs.statSync(writepath); 49 | const fileSizeKb = (stats.size * 8) / 1024; 50 | const bitrateAprox = fileSizeKb / track.duration; 51 | if (Number(track.bitrate) === 3 && bitrateAprox < 310) { 52 | return true; 53 | } 54 | } 55 | 56 | return !trackAlreadyDownloaded; 57 | }; 58 | 59 | export const tagTrack = ( 60 | extension: string, 61 | writepath: string, 62 | track, 63 | tags: Tags 64 | ) => { 65 | if (extension === ".mp3") { 66 | tagID3(writepath, track, tags); 67 | } else if (extension === ".flac") { 68 | tagFLAC(writepath, track, tags); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /deemix/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core.js"; 2 | export * from "./crypto.js"; 3 | export * from "./deezer.js"; 4 | export * from "./downloadUtils.js"; 5 | export * from "./localpaths.js"; 6 | export * from "./pathtemplates.js"; 7 | export * from "./getPreferredBitrate.js"; 8 | export * from "./downloadImage.js"; 9 | -------------------------------------------------------------------------------- /deemix/src/utils/pathtemplates.test.ts: -------------------------------------------------------------------------------- 1 | import { fixName, pad } from "./pathtemplates.js"; 2 | 3 | test("fix name", async () => { 4 | const fixed = fixName("track/:*"); 5 | expect(fixed).toBe("track___"); 6 | }); 7 | 8 | test("pad name", () => { 9 | const settings = { 10 | paddingSize: 0, 11 | padTracks: true, 12 | padSingleDigit: true, 13 | }; 14 | 15 | expect(pad(1, 12, settings)).toEqual("01"); 16 | expect(pad(12, 12, settings)).toEqual("12"); 17 | 18 | settings.paddingSize = 4; 19 | expect(pad(1, 2, settings)).toEqual("0001"); 20 | expect(pad(12, 12, settings)).toEqual("0012"); 21 | 22 | settings.padSingleDigit = false; 23 | settings.paddingSize = 1; 24 | expect(pad(1, 12, settings)).toEqual("1"); 25 | expect(pad(12, 12, settings)).toEqual("12"); 26 | 27 | settings.padTracks = false; 28 | 29 | expect(pad(1, 12, settings)).toEqual("1"); 30 | expect(pad(12, 12, settings)).toEqual("12"); 31 | }); 32 | -------------------------------------------------------------------------------- /deemix/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/tsc/no-dom/library-monorepo", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "strict": false, 7 | // "strictNullChecks": true, 8 | "declaration": true, 9 | "types": ["vitest/globals"], 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | }, 14 | "include": ["./src/**/*.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /deemix/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | include: ["src/**/*.test.ts"], 7 | globals: true, 8 | }, 9 | plugins: [tsconfigPaths()], 10 | }); 11 | -------------------------------------------------------------------------------- /deezer-sdk/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # deezer-sdk 2 | 3 | ## 1.10.0 4 | 5 | ### Minor Changes 6 | 7 | - 9ea682c: Temporarily disable API caching 8 | 9 | ## 1.9.0 10 | 11 | ### Minor Changes 12 | 13 | - 5f610aa: Fix caching to allow search pagination to work properly 14 | 15 | ## 1.8.1 16 | 17 | ### Patch Changes 18 | 19 | - c464740: Replace redis integration with memory cache 20 | 21 | ## 1.8.0 22 | 23 | ### Minor Changes 24 | 25 | - 4a38238: Use redis to cache Deezer API calls 26 | - 0d08d68: Rename to deezer-sdk 27 | 28 | ## 1.7.0 29 | 30 | ### Minor Changes 31 | 32 | - 1453cc7: Ship package as compiled js 33 | 34 | ### Patch Changes 35 | 36 | - 15e03de: Include type definitions with bundle 37 | 38 | ## 1.6.0 39 | 40 | ### Minor Changes 41 | 42 | - a2e64c5: Significantly improved typing 43 | 44 | ## 1.5.0 45 | 46 | ### Minor Changes 47 | 48 | - ad518d0: Refactor deezer-sdk to typescript 49 | 50 | ## 1.4.0 51 | 52 | ### Minor Changes 53 | 54 | - 59b9efc: Monorepo Refactor 55 | -------------------------------------------------------------------------------- /deezer-sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deezer-sdk", 3 | "version": "1.10.0", 4 | "private": true, 5 | "description": "A wrapper for all Deezer's APIs", 6 | "type": "module", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "dev": "run-p watch:*", 11 | "watch:build": "tsup src/index.ts --format esm --watch", 12 | "watch:tsc": "tsc --emitDeclarationOnly -w", 13 | "build": "run-s build:*", 14 | "build:tsup": "tsup src/index.ts --format esm", 15 | "build:tsc": "tsc --emitDeclarationOnly", 16 | "test": "vitest run", 17 | "test:watch": "vitest", 18 | "lint": "eslint .", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/bambanah/deemix" 24 | }, 25 | "author": "Bambanah", 26 | "license": "GPL-3.0-or-later", 27 | "dependencies": { 28 | "got": "^14.4.2", 29 | "nanostores": "^0.11.3", 30 | "tough-cookie": "^4.0.0", 31 | "zod": "^3.23.8" 32 | }, 33 | "devDependencies": { 34 | "@types/tough-cookie": "^4.0.5", 35 | "tsup": "^8.2.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /deezer-sdk/src/errors.ts: -------------------------------------------------------------------------------- 1 | export class DeezerError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = "DeezerError"; 5 | } 6 | } 7 | 8 | export class WrongLicense extends DeezerError { 9 | format: string; 10 | 11 | constructor(format: string) { 12 | super(`Your account can't request urls for ${format} tracks`); 13 | this.name = "WrongLicense"; 14 | this.format = format; 15 | } 16 | } 17 | 18 | export class WrongGeolocation extends DeezerError { 19 | country?: string; 20 | 21 | constructor(country?: string) { 22 | super(`The track you requested can't be streamed in country ${country}`); 23 | this.name = "WrongGeolocation"; 24 | this.country = country; 25 | } 26 | } 27 | 28 | // APIError 29 | export class APIError extends DeezerError { 30 | constructor(message: string) { 31 | super(message); 32 | this.name = "APIError"; 33 | } 34 | } 35 | export class ItemsLimitExceededException extends APIError { 36 | constructor(message: string) { 37 | super(message); 38 | this.name = "ItemsLimitExceededException"; 39 | } 40 | } 41 | export class PermissionException extends APIError { 42 | constructor(message: string) { 43 | super(message); 44 | this.name = "PermissionException"; 45 | } 46 | } 47 | export class InvalidTokenException extends APIError { 48 | constructor(message: string) { 49 | super(message); 50 | this.name = "InvalidTokenException"; 51 | } 52 | } 53 | export class WrongParameterException extends APIError { 54 | constructor(message: string) { 55 | super(message); 56 | this.name = "WrongParameterException"; 57 | } 58 | } 59 | export class MissingParameterException extends APIError { 60 | constructor(message: string) { 61 | super(message); 62 | this.name = "MissingParameterException"; 63 | } 64 | } 65 | export class InvalidQueryException extends APIError { 66 | constructor(message: string) { 67 | super(message); 68 | this.name = "InvalidQueryException"; 69 | } 70 | } 71 | export class DataException extends APIError { 72 | constructor(message: string) { 73 | super(message); 74 | this.name = "DataException"; 75 | } 76 | } 77 | export class IndividualAccountChangedNotAllowedException extends APIError { 78 | constructor(message: string) { 79 | super(message); 80 | this.name = "IndividualAccountChangedNotAllowedException"; 81 | } 82 | } 83 | export class GWAPIError extends DeezerError { 84 | constructor(message: string) { 85 | super(message); 86 | this.name = "GWAPIError"; 87 | this.message = "Track unavailable on Deezer"; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /deezer-sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./deezer.js"; 2 | export * from "./types.js"; 3 | export * from "./api.js"; 4 | export * from "./gw.js"; 5 | export * as utils from "./utils.js"; 6 | export * as errors from "./errors.js"; 7 | export { setDeezerCacheDir } from "./store.js"; 8 | export { type DeezerTrack } from "./schema/track-schema.js"; 9 | -------------------------------------------------------------------------------- /deezer-sdk/src/schema/album-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { artistSchema, contributorSchema } from "./contributor-schema.js"; 3 | import { albumTrackSchema } from "./track-schema.js"; 4 | 5 | export const trackAlbumSchema = z.object({ 6 | id: z.number(), 7 | title: z.string(), 8 | link: z.string(), 9 | cover: z.string(), 10 | cover_small: z.string(), 11 | cover_medium: z.string(), 12 | cover_big: z.string(), 13 | cover_xl: z.string(), 14 | md5_image: z.string(), 15 | release_date: z.string().optional(), 16 | tracklist: z.string(), 17 | type: z.literal("album"), 18 | }); 19 | 20 | export type TrackAlbum = z.infer; 21 | 22 | export const albumSchema = trackAlbumSchema.extend({ 23 | upc: z.string(), 24 | genre_id: z.number(), 25 | genres: z.object({ data: z.array(z.string()) }), 26 | label: z.string(), 27 | nb_tracks: z.number(), 28 | duration: z.number(), 29 | fans: z.number(), 30 | record_type: z.string(), 31 | available: z.boolean(), 32 | explicit_lyrics: z.boolean(), 33 | explicit_content_lyrics: z.number(), 34 | explicit_content_cover: z.number(), 35 | contributors: z.array(z.lazy(() => contributorSchema)), 36 | artist: artistSchema, 37 | tracks: z.object({ data: z.array(z.lazy(() => albumTrackSchema)) }), 38 | }); 39 | 40 | export type DeezerAlbum = z.infer; 41 | -------------------------------------------------------------------------------- /deezer-sdk/src/schema/contributor-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const artistSchema = z.object({ 4 | id: z.number(), 5 | name: z.string(), 6 | link: z.string().optional(), 7 | share: z.string().optional(), 8 | picture: z.string(), 9 | picture_small: z.string().optional(), 10 | picture_medium: z.string().optional(), 11 | picture_big: z.string().optional(), 12 | picture_xl: z.string().optional(), 13 | tracklist: z.string().optional(), 14 | md5_image: z.string().optional(), 15 | type: z.literal("artist"), 16 | }); 17 | 18 | export type Artist = z.infer; 19 | 20 | export const contributorSchema = artistSchema.extend({ 21 | role: z.string(), 22 | }); 23 | 24 | export type Contributor = z.infer; 25 | -------------------------------------------------------------------------------- /deezer-sdk/src/schema/playlist-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const playlistSchema = z.object({ 4 | id: z.string(), 5 | }); 6 | -------------------------------------------------------------------------------- /deezer-sdk/src/schema/track-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { artistSchema, contributorSchema } from "./contributor-schema.js"; 3 | import { trackAlbumSchema } from "./album-schema.js"; 4 | 5 | export const baseTrackSchema = z.object({ 6 | id: z.number(), 7 | readable: z.boolean(), 8 | title: z.string(), 9 | title_short: z.string(), 10 | title_version: z.string(), 11 | link: z.string(), 12 | duration: z.number(), 13 | rank: z.number(), 14 | explicit_lyrics: z.boolean(), 15 | explicit_content_lyrics: z.number(), 16 | explicit_content_cover: z.number(), 17 | preview: z.string(), 18 | md5_image: z.string(), 19 | size: z.number().optional(), 20 | lyrics_id: z.string().optional(), 21 | lyrics: z.string().optional(), 22 | position: z.number().optional(), 23 | copyright: z.string().optional(), 24 | physical_release_date: z.string().optional(), 25 | genres: z.array(z.string()).optional(), 26 | type: z.literal("track"), 27 | }); 28 | 29 | export const albumTrackSchema = baseTrackSchema.extend({ 30 | artist: z.object({ 31 | id: z.number(), 32 | name: z.string(), 33 | tracklist: z.string(), 34 | type: z.literal("artist"), 35 | }), 36 | album: z.object({ 37 | id: z.number(), 38 | title: z.string(), 39 | cover: z.string(), 40 | cover_small: z.string(), 41 | cover_medium: z.string(), 42 | cover_big: z.string(), 43 | cover_xl: z.string(), 44 | tracklist: z.string(), 45 | type: z.literal("album"), 46 | }), 47 | }); 48 | 49 | export const trackSchema = baseTrackSchema.extend({ 50 | isrc: z.string(), 51 | share: z.string(), 52 | track_position: z.number(), 53 | disk_number: z.number(), 54 | release_date: z.string(), 55 | bpm: z.number(), 56 | gain: z.number(), 57 | md5_origin: z.number().optional(), 58 | available_countries: z.array(z.string()), 59 | alternative: z.lazy(() => trackSchema).optional(), 60 | contributors: z.array(z.lazy(() => contributorSchema)).optional(), 61 | track_token: z.string(), 62 | artist: artistSchema, 63 | album: z.lazy(() => trackAlbumSchema), 64 | }); 65 | 66 | export type DeezerTrack = z.infer; 67 | -------------------------------------------------------------------------------- /deezer-sdk/src/store.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "nanostores"; 2 | 3 | export const $cacheDir = atom(); 4 | 5 | export const setDeezerCacheDir = (dir: string) => { 6 | $cacheDir.set(dir); 7 | }; 8 | -------------------------------------------------------------------------------- /deezer-sdk/src/tests/api.test.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "../deezer.js"; 2 | 3 | const deezer = new Deezer(); 4 | 5 | test("retrieves a track", async () => { 6 | const track = await deezer.api.getTrack(1283264142); 7 | expect(track.title).toBe("MONTERO (Call Me By Your Name)"); 8 | }); 9 | 10 | test("retrieves a track by ISRC", async () => { 11 | const track = await deezer.api.getTrackByISRC("USSM12100531"); 12 | expect(track.title).toBe("MONTERO (Call Me By Your Name)"); 13 | }); 14 | -------------------------------------------------------------------------------- /deezer-sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/tsc/no-dom/library-monorepo", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "strict": false, 7 | "strictNullChecks": true, 8 | "declaration": true, 9 | "types": ["vitest/globals"] 10 | }, 11 | "include": ["./src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /deezer-sdk/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["src/**/*.test.ts"], 6 | globals: true, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | deemix: 3 | container_name: Deemix 4 | build: . 5 | restart: unless-stopped 6 | ports: 7 | - 6595:6595 8 | volumes: 9 | - "${DEEMIX_CONFIG_PATH}:/config" # Set the `DEEMIX_CONFIG_PATH` environment variable in the shell where `docker-compose up` is being ran 10 | - "${DEEMIX_MUSIC_PATH}:/downloads" # Set the `DEEMIX_MUSIC_PATH` environment variable in the shell where `docker-compose up` is being ran 11 | -------------------------------------------------------------------------------- /docker/etc/cont-init.d/10-fix_folders: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | # shellcheck disable=SC1008 3 | 4 | printf '[cont-init.d] Creating Folders if Missing\n' 5 | 6 | mkdir -p "$DEEMIX_MUSIC_DIR" 7 | mkdir -p "$DEEMIX_DATA_DIR" 8 | 9 | printf '[cont-init.d] Fixing Folder Permissions - Config Folder\n' 10 | 11 | chown -R "$PUID":"$PGID" "$DEEMIX_MUSIC_DIR" 12 | 13 | if [ -n "${DISABLE_OWNERSHIP_CHECK}" ]; then 14 | printf '[cont-init.d] Download Folder Ownership Check disabled by Environment Variable\n' 15 | else 16 | printf '[cont-init.d] Fixing Folder Permissions - Downloads Folder\n' 17 | chown -R "$PUID":"$PGID" "$DEEMIX_DATA_DIR" 18 | fi 19 | 20 | # Fix misconfigured download locations. The container's download map is always /downloads. 21 | if [ -f "/config/config.json" ]; then 22 | jq '.downloadLocation = "/downloads"' /config/config.json >tmp.$$.json && mv tmp.$$.json /config/config.json 23 | chown "$PUID":"$PGID" /config/config.json 24 | fi 25 | -------------------------------------------------------------------------------- /docker/etc/cont-init.d/15-checks: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | # shellcheck disable=SC1008 3 | 4 | printf '[cont-init.d] Testing Access\n' 5 | 6 | if [ -w "/downloads" ]; then 7 | printf '%-50s %2s %-5s \n' "[cont-init.d] Download Folder Write Access" ":" "Success" 8 | else 9 | printf '%-50s %2s %-5s \n' "[cont-init.d] Download Folder Write Access" ":" "Failure" 10 | fi 11 | 12 | if [ -w "/config" ]; then 13 | printf '%-50s %2s %-5s \n' "[cont-init.d] Config Folder Write Access" ":" "Success" 14 | else 15 | printf '%-50s %2s %-5s \n' "[cont-init.d] Config Folder Write Access" ":" "Failure" 16 | fi 17 | 18 | until curl --fail -sf www.deezer.com; do 19 | printf '%-50s %2s %-5s \n' "[cont-init.d] Internet Access" ":" "Failure. Trying again in 5 seconds" 20 | sleep 5 21 | done 22 | 23 | printf '%-50s %2s %-5s \n' "[cont-init.d] Internet Access" ":" "Success" 24 | -------------------------------------------------------------------------------- /docker/etc/services.d/deemix/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | # shellcheck disable=SC1008 3 | 4 | UMASK_SET=${UMASK_SET:-022} 5 | umask "$UMASK_SET" 6 | 7 | echo "[services.d] Starting Deemix" 8 | s6-setuidgid abc node /app/webui/dist/main.js 9 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from "@eslint/js"; 3 | import eslintConfigPrettier from "eslint-config-prettier"; 4 | import pluginVue from "eslint-plugin-vue"; 5 | import globals from "globals"; 6 | import tslint from "typescript-eslint"; 7 | 8 | const extraFileExtensions = [".vue"]; 9 | 10 | export default tslint.config( 11 | eslint.configs.recommended, 12 | ...tslint.configs.recommended, 13 | ...pluginVue.configs["flat/recommended"], 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.browser, 18 | ...globals.node, 19 | }, 20 | }, 21 | }, 22 | { 23 | files: ["**/*.ts", "**/*.vue"], 24 | languageOptions: { 25 | parserOptions: { 26 | parser: tslint.parser, 27 | extraFileExtensions, 28 | }, 29 | }, 30 | }, 31 | { 32 | files: ["webui/**/*"], 33 | rules: { 34 | "vue/no-v-html": "off", 35 | "vue/require-explicit-emits": "off", 36 | "no-unused-vars": "off", 37 | "@typescript-eslint/no-explicit-any": "off", 38 | "@typescript-eslint/ban-ts-comment": "off", 39 | "no-console": ["error", { allow: ["warn", "error", "trace"] }], 40 | }, 41 | }, 42 | { 43 | files: ["deemix/**/*", "deezer-sdk/**/*"], 44 | rules: { 45 | "@typescript-eslint/no-explicit-any": "off", 46 | "no-console": ["error", { allow: ["warn", "error", "trace"] }], 47 | }, 48 | }, 49 | { ignores: ["**/node_modules/", "**/dist/"] }, 50 | eslintConfigPrettier 51 | ); 52 | -------------------------------------------------------------------------------- /gui/build/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/gui/build/64x64.png -------------------------------------------------------------------------------- /gui/build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/gui/build/icon.icns -------------------------------------------------------------------------------- /gui/build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/gui/build/icon.ico -------------------------------------------------------------------------------- /gui/forge.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | packagerConfig: { 3 | name: "Deemix", 4 | asar: true, 5 | prune: false, 6 | ignore: [ 7 | /^\/node_modules/, 8 | /^\/out/, 9 | /^\/src/, 10 | /^\/public/, 11 | /^\/scripts/, 12 | /^\/.gitignore/, 13 | /^\/forge.config.js/, 14 | /^\/tsconfig.json/, 15 | ], 16 | icon: "./build/icon.ico", 17 | executableName: "deemix-gui", 18 | }, 19 | rebuildConfig: {}, 20 | makers: [ 21 | { 22 | name: "@electron-forge/maker-squirrel", 23 | config: {}, 24 | }, 25 | { 26 | name: "@electron-forge/maker-zip", 27 | config: {}, 28 | }, 29 | { 30 | name: "@electron-forge/maker-deb", 31 | config: { 32 | options: { 33 | name: "deemix", 34 | productName: "Deemix", 35 | section: "sound", 36 | icon: "./build/icon.ico", 37 | categories: ["Audio"], 38 | }, 39 | }, 40 | }, 41 | ], 42 | plugins: [ 43 | { 44 | name: "@electron-forge/plugin-auto-unpack-natives", 45 | config: {}, 46 | }, 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /gui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deemix-gui", 3 | "version": "0.3.9", 4 | "private": true, 5 | "description": "A standalone electron app that wraps deemix-webui", 6 | "main": "dist/main.js", 7 | "homepage": "https://github.com/bambanah/deemix", 8 | "author": "Bambanah ", 9 | "license": "GPL-3.0-only", 10 | "type": "module", 11 | "scripts": { 12 | "dev": "electron .", 13 | "build": "node ./scripts/build.js --mode production", 14 | "package": "electron-forge package", 15 | "make": "electron-forge make", 16 | "lint": "eslint .", 17 | "type-check": "tsc --noEmit" 18 | }, 19 | "devDependencies": { 20 | "@electron-forge/cli": "^7.5.0", 21 | "@electron-forge/maker-deb": "^7.5.0", 22 | "@electron-forge/maker-dmg": "^7.5.0", 23 | "@electron-forge/maker-rpm": "^7.5.0", 24 | "@electron-forge/maker-squirrel": "^7.5.0", 25 | "@electron-forge/maker-zip": "^7.5.0", 26 | "@electron-forge/plugin-auto-unpack-natives": "^7.5.0", 27 | "@electron/rebuild": "^3.7.0", 28 | "@types/yargs": "^17.0.33", 29 | "electron": "33.0.2", 30 | "esbuild": "^0.24.0", 31 | "node-gyp": "^10.2.0", 32 | "tsup": "^8.3.5" 33 | }, 34 | "dependencies": { 35 | "deemix-webui": "workspace:*", 36 | "electron-context-menu": "^4.0.4", 37 | "electron-squirrel-startup": "^1.0.1", 38 | "electron-window-state-manager": "^0.3.2", 39 | "yargs": "^17.7.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /gui/scripts/cjs-shim.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | import path from "node:path"; 3 | import url from "node:url"; 4 | 5 | globalThis.require = createRequire(import.meta.url); 6 | globalThis.__filename = url.fileURLToPath(import.meta.url); 7 | globalThis.__dirname = path.dirname(__filename); 8 | -------------------------------------------------------------------------------- /gui/scripts/utils.js: -------------------------------------------------------------------------------- 1 | import child_process from "node:child_process"; 2 | 3 | /** 4 | * spawnp 5 | * @param {string} command 6 | * @param {child_process.SpawnOptionsWithoutStdio | undefined} options 7 | */ 8 | export function spawnp(command, options) { 9 | return new Promise((resolve, reject) => { 10 | const child = child_process.spawn(command, options); 11 | child.on("close", (code) => { 12 | if (code === 0) { 13 | resolve(); 14 | } else { 15 | reject(new Error(`Command ${command} exited with code ${code}`)); 16 | } 17 | }); 18 | process.on("exit", () => { 19 | child.kill(); 20 | }); 21 | }); 22 | } 23 | 24 | export function getArg(argv, key) { 25 | const index = argv.indexOf(key); 26 | if (index != -1) { 27 | return argv[index + 1]; 28 | } 29 | } 30 | 31 | export function hasArg(argv, key) { 32 | return argv.includes(key); 33 | } 34 | 35 | export const log = { 36 | name: "log", 37 | setup(build) { 38 | // Print information at the start of the build 39 | build.onStart(() => { 40 | const entryPoints = build.initialOptions.entryPoints; 41 | console.log("[build] Start building:", entryPoints.join(", ")); 42 | }); 43 | 44 | // Print information at the end of the build 45 | build.onEnd(() => { 46 | const entryPoints = build.initialOptions.entryPoints; 47 | console.log("[build] Build completed:", entryPoints.join(", ")); 48 | }); 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /gui/src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | 3 | // Expose protected methods that allow the renderer process to use 4 | // the ipcRenderer without exposing the entire object 5 | contextBridge.exposeInMainWorld("api", { 6 | send: (channel, data) => { 7 | // whitelist channels 8 | const validChannels = ["openDownloadsFolder", "selectDownloadFolder"]; 9 | if (validChannels.includes(channel)) { 10 | ipcRenderer.send(channel, data); 11 | } 12 | }, 13 | receive: (channel, func) => { 14 | const validChannels = ["downloadFolderSelected"]; 15 | if (validChannels.includes(channel)) { 16 | // Deliberately strip event as it includes `sender` 17 | ipcRenderer.on(channel, (event, ...args) => func(...args)); 18 | } 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /gui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": false, 7 | "esModuleInterop": true, 8 | "declaration": true, 9 | "resolveJsonModule": true, 10 | "rootDir": "./src", 11 | "outDir": "./dist", 12 | "skipLibCheck": true, 13 | "skipDefaultLibCheck": true 14 | }, 15 | "include": ["./src/**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | "*.{js,jsx,ts,tsx,vue}": "eslint --fix", 3 | "*": "prettier --write --ignore-unknown", 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deemix", 3 | "private": true, 4 | "description": "A hybrid monorepo that wraps deemix-webui and lets you use the deemix-js library", 5 | "repository": "https://github.com/bambanah/deemix.git", 6 | "author": "Bambanah", 7 | "license": "GPL-3.0-only", 8 | "scripts": { 9 | "dev": "turbo run dev --filter=deemix-webui...", 10 | "dev:gui": "turbo run deemix-gui#dev", 11 | "build": "turbo run build", 12 | "lint": "turbo run lint", 13 | "type-check": "turbo run type-check", 14 | "test": "turbo run test", 15 | "package": "turbo run package", 16 | "make": "turbo run make", 17 | "compile:cli": "turbo run deemix-cli#compile", 18 | "ci": "turbo run lint type-check build test", 19 | "clean": "rimraf .turbo node_modules --glob **/dist **/out **/node_modules **/.turbo **/tsconfig.tsbuildinfo", 20 | "prepare": "husky" 21 | }, 22 | "devDependencies": { 23 | "@changesets/cli": "^2.28.1", 24 | "@commitlint/cli": "^19.8.0", 25 | "@commitlint/config-conventional": "^19.8.0", 26 | "@eslint/js": "^9.22.0", 27 | "@total-typescript/tsconfig": "^1.0.4", 28 | "@types/eslint-config-prettier": "^6.11.3", 29 | "@types/node": "^20.16.3", 30 | "@typescript-eslint/parser": "~8.26.1", 31 | "esbuild": "^0.25.1", 32 | "eslint": "^9.22.0", 33 | "eslint-config-prettier": "^10.1.1", 34 | "eslint-plugin-vue": "^10.0.0", 35 | "globals": "^16.0.0", 36 | "husky": "^9.1.7", 37 | "npm-run-all2": "^7.0.2", 38 | "prettier": "^3.5.3", 39 | "prettier-plugin-tailwindcss": "^0.6.11", 40 | "rimraf": "^6.0.1", 41 | "tsx": "^4.19.3", 42 | "turbo": "^2.4.4", 43 | "typescript": "~5.8.2", 44 | "typescript-eslint": "8.26.1", 45 | "vitest": "^2.0.5", 46 | "vue-eslint-parser": "^10.1.1" 47 | }, 48 | "packageManager": "pnpm@10.6.4+sha512.da3d715bfd22a9a105e6e8088cfc7826699332ded60c423b14ec613a185f1602206702ff0fe4c438cb15c979081ce4cb02568e364b15174503a63c7a8e2a5f6c" 49 | } 50 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - webui 3 | - deemix 4 | - deezer-sdk 5 | - gui 6 | - cli 7 | onlyBuiltDependencies: 8 | - electron 9 | - electron-winstaller 10 | - esbuild 11 | - utf-8-validate 12 | - vue-demi 13 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | export default { 3 | plugins: ["prettier-plugin-tailwindcss"], 4 | singleQuote: false, 5 | useTabs: true, 6 | endOfLine: "lf", 7 | trailingComma: "es5", 8 | }; 9 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "tasks": { 4 | "lint": { 5 | "outputs": [] 6 | }, 7 | "dev": { 8 | "persistent": true, 9 | "cache": false 10 | }, 11 | "build": { 12 | "dependsOn": ["^build"], 13 | "outputs": ["dist/**"] 14 | }, 15 | "type-check": { 16 | "dependsOn": ["^build"], 17 | "outputs": [] 18 | }, 19 | "test": { 20 | "dependsOn": ["^build"] 21 | }, 22 | "package": { 23 | "dependsOn": ["build", "^build"], 24 | "outputs": ["dist/**", "out/**"] 25 | }, 26 | "make": { 27 | "dependsOn": ["build", "^build"], 28 | "outputs": ["dist/**", "out/**"] 29 | }, 30 | "compile": { 31 | "dependsOn": ["build", "^build"], 32 | "outputs": ["dist/**", "out/**"] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "deploymentEnabled": false 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from "vitest/config"; 2 | 3 | export default defineWorkspace([ 4 | "./deezer-sdk/vitest.config.ts", 5 | "./webui/vitest.config.ts", 6 | "./deemix/vitest.config.ts", 7 | "./cli/vitest.config.ts", 8 | ]); 9 | -------------------------------------------------------------------------------- /webui/README.md: -------------------------------------------------------------------------------- 1 | # "Hidden" features 2 | 3 | - `CTRL+SHIFT+Backspace` deletes all the search bar content 4 | - `CTRL+F` focuses the search bar 5 | - `CTRL+B` toggles the download bar 6 | - `ALT+Left` goes back to the previous page, if present (like would happen in the browser) 7 | - `ALT+Right` goes forward to the next page, if present (like would happen in the browser) 8 | - Custom context menu: on certain elements, like download buttons or album covers, when opening the context menu, a custom one with more options will appear instead of the default one 9 | -------------------------------------------------------------------------------- /webui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Deemix 6 | 7 | 11 | 17 | 25 | 26 | 27 | 30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /webui/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /webui/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/favicon.png -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOUehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOUehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOUuhp.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOUuhp.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOVuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOVuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOX-hpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOX-hpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOXOhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOXOhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOXehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOXehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOXuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN7rgOXuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOUehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOUehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOUuhp.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOUuhp.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOVuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOVuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOX-hpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOX-hpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOXOhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOXOhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOXehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOXehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOXuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN8rsOXuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOUuhp.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOUuhp.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFUZ0bbck.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFUZ0bbck.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFVZ0b.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFVZ0b.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFVp0bbck.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFVp0bbck.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFW50bbck.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFW50bbck.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFWJ0bbck.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFWJ0bbck.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFWZ0bbck.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFWZ0bbck.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFWp0bbck.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/mem8YaGs126MiZpBA-UFWp0bbck.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hkIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hkIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hlIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hlIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hmIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hmIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hnIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hnIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hoIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hoIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hrIqM.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hrIqM.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hvIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKW-U9hvIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhkIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhkIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhlIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhlIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhmIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhmIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhnIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhnIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhoIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhoIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhrIqM.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhrIqM.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhvIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWiUNhvIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/OpenSans/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 -------------------------------------------------------------------------------- /webui/public/fonts/icons/MaterialIcons-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/fonts/icons/MaterialIcons-Regular.ttf -------------------------------------------------------------------------------- /webui/public/res/InfoSpotifyFeatures/ClientIdSecret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/res/InfoSpotifyFeatures/ClientIdSecret.png -------------------------------------------------------------------------------- /webui/public/res/InfoSpotifyFeatures/CreateApp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/res/InfoSpotifyFeatures/CreateApp.png -------------------------------------------------------------------------------- /webui/public/res/InfoSpotifyFeatures/CreateAppForm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bambanah/deemix/c0ecb2c9c03c7d0c5bc3c7a365962b49a8f24bec/webui/public/res/InfoSpotifyFeatures/CreateAppForm.png -------------------------------------------------------------------------------- /webui/src/client/App.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 57 | 58 | 69 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/BackButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/BaseAccordion.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 32 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/BaseLoadingPlaceholder.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | 26 | 65 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/BaseTab.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/BaseTabs.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/CoverContainer.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 41 | 42 | 77 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/DeezerWarning.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | 20 | 43 | -------------------------------------------------------------------------------- /webui/src/client/components/globals/PreviewControls.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 26 | -------------------------------------------------------------------------------- /webui/src/client/components/search/ResultsAlbums.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 84 | -------------------------------------------------------------------------------- /webui/src/client/components/search/ResultsArtists.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 67 | -------------------------------------------------------------------------------- /webui/src/client/components/search/ResultsError.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /webui/src/client/components/search/ResultsPlaylists.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 77 | -------------------------------------------------------------------------------- /webui/src/client/components/search/TopResult.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 79 | -------------------------------------------------------------------------------- /webui/src/client/components/settings/TemplateVariablesList.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 31 | -------------------------------------------------------------------------------- /webui/src/client/data/artist.ts: -------------------------------------------------------------------------------- 1 | import { getPropertyWithFallback } from "@/utils/utils"; 2 | import { fetchData } from "@/utils/api-utils"; 3 | 4 | export function formatArtistData(artistData: { 5 | name?: string; 6 | picture_xl?: string; 7 | releases?: { [key: string]: any[] }; 8 | }) { 9 | return { 10 | artistName: Object.prototype.hasOwnProperty.call(artistData, "name") 11 | ? artistData.name 12 | : undefined, 13 | artistPictureXL: Object.prototype.hasOwnProperty.call( 14 | artistData, 15 | "picture_xl" 16 | ) 17 | ? artistData.picture_xl 18 | : undefined, 19 | artistReleases: Object.prototype.hasOwnProperty.call(artistData, "releases") 20 | ? formatArtistReleases(artistData.releases) 21 | : undefined, 22 | }; 23 | } 24 | 25 | interface ArtistRelease { 26 | releaseID: string; 27 | releaseCover: string; 28 | releaseTitle: string; 29 | releaseDate: string; 30 | releaseTracksNumber: number; 31 | releaseLink: string; 32 | releaseType: string; 33 | isReleaseExplicit: boolean; 34 | } 35 | 36 | function formatArtistReleases( 37 | artistReleases: { [key: string]: any[] } | undefined 38 | ) { 39 | const formattedReleases: Record = {}; 40 | 41 | for (const releaseType in artistReleases) { 42 | const releases = artistReleases[releaseType]; 43 | formattedReleases[releaseType] = []; 44 | 45 | for (const release of releases) { 46 | formattedReleases[releaseType].push({ 47 | releaseID: getPropertyWithFallback(release, "id"), 48 | releaseCover: getPropertyWithFallback(release, "cover_small"), 49 | releaseTitle: getPropertyWithFallback(release, "title"), 50 | releaseDate: getPropertyWithFallback(release, "release_date"), 51 | releaseTracksNumber: getPropertyWithFallback(release, "nb_tracks"), 52 | releaseLink: getPropertyWithFallback(release, "link"), 53 | releaseType: getPropertyWithFallback(release, "record_type"), 54 | isReleaseExplicit: getPropertyWithFallback(release, "explicit_lyrics"), 55 | }); 56 | } 57 | } 58 | 59 | return formattedReleases; 60 | } 61 | 62 | export function getArtistData(artistID?: string) { 63 | return fetchData("getTracklist", { 64 | type: "artist", 65 | id: artistID, 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /webui/src/client/data/charts.ts: -------------------------------------------------------------------------------- 1 | import { fetchData } from "@/utils/api-utils"; 2 | 3 | export function getChartsData() { 4 | return fetchData("getCharts"); 5 | } 6 | 7 | export function getChartTracks(chartId: string) { 8 | return fetchData("getChartTracks", { id: chartId }); 9 | } 10 | -------------------------------------------------------------------------------- /webui/src/client/data/file-templates.ts: -------------------------------------------------------------------------------- 1 | // TODO: Use JSON 2 | 3 | export const trackTemplateVariables = [ 4 | "%title%", 5 | "%artist%", 6 | "%artists%", 7 | "%allartists%", 8 | "%mainartists%", 9 | "%featartists%", 10 | "%album%", 11 | "%albumartist%", 12 | "%tracknumber%", 13 | "%tracktotal%", 14 | "%discnumber%", 15 | "%disctotal%", 16 | "%genre%", 17 | "%year%", 18 | "%date%", 19 | "%bpm%", 20 | "%label%", 21 | "%isrc%", 22 | "%upc%", 23 | "%explicit%", 24 | "%track_id%", 25 | "%album_id%", 26 | "%artist_id%", 27 | "%playlist_id%", 28 | "%position%", 29 | ]; 30 | 31 | export const albumFolderTemplateVariables = [ 32 | "%album_id%", 33 | "%genre%", 34 | "%album%", 35 | "%artist%", 36 | "%artist_id%", 37 | "%root_artist%", 38 | "%root_artist_id%", 39 | "%tracktotal%", 40 | "%disctotal%", 41 | "%type%", 42 | "%upc%", 43 | "%explicit%", 44 | "%label%", 45 | "%year%", 46 | "%date%", 47 | "%bitrate%", 48 | ]; 49 | 50 | export const artistFolderTemplateVariables = [ 51 | "%artist%", 52 | "%artist_id%", 53 | "%root_artist%", 54 | "%root_artist_id%", 55 | ]; 56 | 57 | export const playlistFolderTemplateVariables = [ 58 | "%playlist%", 59 | "%playlist_id%", 60 | "%owner%", 61 | "%owner_id%", 62 | "%year%", 63 | "%date%", 64 | "%explicit%", 65 | ]; 66 | 67 | export const playlistFilenameTemplateVariables = [ 68 | "%title%", 69 | "%artist%", 70 | "%size%", 71 | "%type%", 72 | "%id%", 73 | "%bitrate%", 74 | ]; 75 | -------------------------------------------------------------------------------- /webui/src/client/data/home.ts: -------------------------------------------------------------------------------- 1 | import { fetchData } from "@/utils/api-utils"; 2 | 3 | let homeData = {}; 4 | let cached = false; 5 | 6 | export async function getHomeData() { 7 | if (cached) { 8 | return homeData; 9 | } else { 10 | const data = await fetchData("getHome"); 11 | 12 | homeData = data; 13 | cached = true; 14 | 15 | return data; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /webui/src/client/data/qualities.ts: -------------------------------------------------------------------------------- 1 | export const downloadQualities = [ 2 | { 3 | objName: "flac", 4 | label: "FLAC", 5 | value: 9, 6 | }, 7 | { 8 | objName: "320kbps", 9 | label: "MP3 320kbps", 10 | value: 3, 11 | }, 12 | { 13 | objName: "128kbps", 14 | label: "MP3 128kbps", 15 | value: 1, 16 | }, 17 | { 18 | objName: "realityAudioHQ", 19 | label: "360 Reality Audio [HQ]", 20 | value: 15, 21 | }, 22 | { 23 | objName: "realityAudioMQ", 24 | label: "360 Reality Audio [MQ]", 25 | value: 14, 26 | }, 27 | { 28 | objName: "realityAudioLQ", 29 | label: "360 Reality Audio [LQ]", 30 | value: 13, 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /webui/src/client/data/settings.ts: -------------------------------------------------------------------------------- 1 | import { fetchData } from "@/utils/api-utils"; 2 | 3 | let settingsData = {}; 4 | let defaultSettingsData = {}; 5 | let spotifyCredentials: any = {}; 6 | 7 | export async function getSettingsData() { 8 | const data = await fetchData("getSettings"); 9 | const { settings, defaultSettings, spotifySettings } = data; 10 | 11 | settingsData = settings; 12 | defaultSettingsData = defaultSettings; 13 | spotifyCredentials = spotifySettings || {}; 14 | 15 | return { settingsData, defaultSettingsData, spotifyCredentials }; 16 | } 17 | 18 | export function getInitialPreviewVolume() { 19 | let volume = parseInt(localStorage.getItem("previewVolume") ?? ""); 20 | 21 | if (isNaN(volume)) { 22 | volume = 80; // Default 23 | localStorage.setItem("previewVolume", volume.toString()); 24 | } 25 | 26 | return volume; 27 | } 28 | -------------------------------------------------------------------------------- /webui/src/client/data/sidebar.ts: -------------------------------------------------------------------------------- 1 | export const links = [ 2 | { 3 | name: "home", 4 | ariaLabel: "home", 5 | routerName: "Home", 6 | icon: "home", 7 | label: "sidebar.home", 8 | }, 9 | { 10 | name: "search", 11 | ariaLabel: "search", 12 | routerName: "Search", 13 | icon: "search", 14 | label: "sidebar.search", 15 | }, 16 | { 17 | name: "charts", 18 | ariaLabel: "charts", 19 | routerName: "Charts", 20 | icon: "show_chart", 21 | label: "sidebar.charts", 22 | }, 23 | { 24 | name: "favorites", 25 | ariaLabel: "favorites", 26 | routerName: "Favorites", 27 | icon: "star", 28 | label: "sidebar.favorites", 29 | }, 30 | // { 31 | // name: "analyzer", 32 | // ariaLabel: "link analyzer", 33 | // routerName: "Link Analyzer", 34 | // icon: "link", 35 | // label: "sidebar.linkAnalyzer", 36 | // }, 37 | { 38 | name: "settings", 39 | ariaLabel: "settings", 40 | routerName: "Settings", 41 | icon: "settings", 42 | label: "sidebar.settings", 43 | }, 44 | { 45 | name: "about", 46 | ariaLabel: "info", 47 | routerName: "About", 48 | icon: "info", 49 | label: "sidebar.about", 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /webui/src/client/data/standardize.ts: -------------------------------------------------------------------------------- 1 | export function standardizeData( 2 | rawObj: { data?: any; hasLoaded?: any; error?: any }, 3 | formatFunc: (data: any) => any 4 | ) { 5 | if (!rawObj.hasLoaded) { 6 | return null; 7 | } else { 8 | const { data: rawData } = rawObj; 9 | const formattedData: any[] = []; 10 | 11 | for (const dataElement of rawData) { 12 | const formatted = formatFunc(dataElement); 13 | 14 | formattedData.push(formatted); 15 | } 16 | 17 | return { 18 | data: formattedData, 19 | hasLoaded: rawObj.hasLoaded, 20 | error: rawObj.error, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /webui/src/client/electron-window.d.ts: -------------------------------------------------------------------------------- 1 | interface ElectronAPI { 2 | send: (channel: string, ...args: any[]) => void; 3 | receive: (channel: string, func: (...args: any[]) => void) => void; 4 | } 5 | 6 | declare global { 7 | interface Window { 8 | api: ElectronAPI; 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /webui/src/client/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare global { 5 | interface Location { 6 | base: string; 7 | } 8 | interface String { 9 | capitalize(): string; 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /webui/src/client/lang/index.js: -------------------------------------------------------------------------------- 1 | // Using ISO 639-1 where possible 2 | import it from "@/lang/it"; 3 | import en from "@/lang/en"; 4 | import es from "@/lang/es"; 5 | import de from "@/lang/de"; 6 | import fr from "@/lang/fr"; 7 | import id from "@/lang/id"; 8 | import pt from "@/lang/pt-pt"; 9 | import pt_br from "@/lang/pt-br"; 10 | import ru from "@/lang/ru"; 11 | import tr from "@/lang/tr"; 12 | import vi from "@/lang/vi"; 13 | import hr from "@/lang/hr"; 14 | import ar from "@/lang/ar"; 15 | import ko from "@/lang/ko"; 16 | import fil from "@/lang/fil"; 17 | import zh_tw from "@/lang/zh-tw"; 18 | import pl from "@/lang/pl"; 19 | import el from "@/lang/el"; 20 | import sr from "@/lang/sr"; 21 | import th from "@/lang/th"; 22 | 23 | export const locales = { 24 | it, 25 | en, 26 | es, 27 | de, 28 | fr, 29 | id, 30 | pt, 31 | pt_br, 32 | ru, 33 | tr, 34 | vi, 35 | hr, 36 | ar, 37 | ko, 38 | fil, 39 | zh_tw, 40 | pl, 41 | el, 42 | sr, 43 | th, 44 | }; 45 | -------------------------------------------------------------------------------- /webui/src/client/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from "vue-i18n"; 2 | 3 | import { locales } from "@/lang"; 4 | 5 | const storedLocale = localStorage.getItem("locale"); 6 | const DEFAULT_LANG = storedLocale || "en"; 7 | 8 | document.querySelector("html").setAttribute("lang", DEFAULT_LANG); 9 | 10 | const i18n = createI18n({ 11 | legacy: false, 12 | locale: DEFAULT_LANG, 13 | fallbackLocale: "en", 14 | messages: locales, 15 | pluralizationRules: { 16 | /** 17 | * @param {number} choice A choice index given by the input to $tc: `$tc('path.to.rule', choiceIndex)` 18 | * @returns A final choice index to select plural word by 19 | */ 20 | ru(choice /*, choicesLength */) { 21 | const n = Math.abs(choice) % 100; 22 | const n1 = n % 10; 23 | 24 | if (n > 10 && n < 20) { 25 | return 2; 26 | } 27 | 28 | if (n1 > 1 && n1 < 5) { 29 | return 1; 30 | } 31 | 32 | if (n1 === 1) { 33 | return 0; 34 | } 35 | 36 | return 2; 37 | }, 38 | }, 39 | }); 40 | 41 | export default i18n; 42 | -------------------------------------------------------------------------------- /webui/src/client/stores/errors.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | 3 | interface ErrorState { 4 | artist: string; 5 | bitrate: string; 6 | cover: string; 7 | downloaded: number; 8 | errors: any[]; 9 | failed: number; 10 | id: string; 11 | progress: number; 12 | silent: boolean; 13 | size: number; 14 | title: string; 15 | type: string; 16 | uuid: string; 17 | } 18 | 19 | export const useErrorStore = defineStore("error", { 20 | state: (): ErrorState => ({ 21 | artist: "", 22 | bitrate: "", 23 | cover: "", 24 | downloaded: 0, 25 | errors: [], 26 | failed: 0, 27 | id: "", 28 | progress: 0, 29 | silent: true, 30 | size: 0, 31 | title: "", 32 | type: "", 33 | uuid: "", 34 | }), 35 | actions: { 36 | setErrors(payload: ErrorState) { 37 | Object.assign(this, payload); 38 | }, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /webui/src/client/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from "pinia"; 2 | 3 | export const pinia = createPinia(); 4 | -------------------------------------------------------------------------------- /webui/src/client/styles/css/helpers.css: -------------------------------------------------------------------------------- 1 | .changing-theme { 2 | /* Applied to ALL elements when changing theme */ 3 | transition: all 200ms ease-in-out; 4 | } 5 | 6 | [v-cloak] { 7 | /* Attribute removed after that a component finished loading */ 8 | display: none; 9 | } 10 | 11 | .coverart { 12 | /* ? Why? */ 13 | background-color: var(--secondary-background); 14 | } 15 | 16 | .release { 17 | display: inline-block; 18 | width: 156px; 19 | } 20 | 21 | .spin { 22 | animation: spin 500ms infinite ease-out reverse; 23 | } 24 | 25 | @keyframes spin { 26 | 0% { 27 | transform: rotate(0deg); 28 | } 29 | 100% { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /webui/src/client/styles/css/icons.css: -------------------------------------------------------------------------------- 1 | .material-icons.title-icon { 2 | margin-right: 0.3125em; 3 | margin-left: -3px; 4 | } 5 | 6 | .material-icons.title-icon.title-icon--right { 7 | margin-right: 0px; 8 | margin-left: 0.3125em; 9 | } 10 | 11 | .material-icons.title-icon.title-icon--explicit { 12 | color: hsl(240, 5%, 59%); 13 | } 14 | 15 | .material-icons.title-icon.title-icon--new { 16 | color: hsl(27, 100%, 50%); 17 | } 18 | 19 | .material-icons.disabled { 20 | @apply cursor-default opacity-50; 21 | } 22 | 23 | .material-icons.mirrored { 24 | transform: scaleX(-1); 25 | } 26 | -------------------------------------------------------------------------------- /webui/src/client/styles/css/normalize.css: -------------------------------------------------------------------------------- 1 | @layer base { 2 | * { 3 | box-sizing: border-box; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | table, 9 | caption, 10 | tbody, 11 | tfoot, 12 | thead, 13 | tr, 14 | th, 15 | td { 16 | margin: 0; 17 | border: 0; 18 | padding: 0; 19 | vertical-align: baseline; 20 | font: inherit; 21 | font-size: 100%; 22 | } 23 | 24 | table { 25 | border-collapse: collapse; 26 | border-spacing: 0; 27 | } 28 | 29 | /* Taken from Tailwind's Preflight */ 30 | button, 31 | [type="button"], 32 | [type="reset"], 33 | [type="submit"] { 34 | appearance: button; 35 | } 36 | 37 | input[type="text"], 38 | input[type="password"], 39 | input[type="number"], 40 | input[type="search"], 41 | input[type="checkbox"], 42 | select { 43 | appearance: none; 44 | } 45 | 46 | [type="number"]::-webkit-inner-spin-button, 47 | [type="number"]::-webkit-outer-spin-button { 48 | height: auto; 49 | } 50 | 51 | button, 52 | [role="button"] { 53 | cursor: pointer; 54 | } 55 | 56 | p { 57 | word-break: break-word; 58 | } 59 | 60 | *, 61 | ::before, 62 | ::after { 63 | border-width: 0; 64 | border-style: solid; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /webui/src/client/styles/css/toasts.css: -------------------------------------------------------------------------------- 1 | .toast-icon { 2 | @apply mr-2 flex items-center; 3 | } 4 | 5 | .circle-loader { 6 | @apply inline-block h-4 w-4; 7 | border: 2px solid var(--primary-color); 8 | border-radius: 50%; 9 | border-bottom: 2px solid var(--secondary-background); 10 | animation: spin 1s linear infinite; 11 | } 12 | 13 | .toastify { 14 | @apply flex items-center; 15 | box-shadow: 16 | 0 3px 6px -1px rgba(0, 0, 0, 0.12), 17 | 0 10px 36px -4px rgba(0, 0, 0, 0.3); 18 | background: var(--toast-background); 19 | color: var(--toast-text); 20 | } 21 | 22 | .toastify toast { 23 | @apply flex items-center; 24 | } 25 | 26 | .toastify .circle-loader { 27 | border-bottom-color: var(--toast-secondary); 28 | } 29 | -------------------------------------------------------------------------------- /webui/src/client/styles/css/typography.css: -------------------------------------------------------------------------------- 1 | .primary-text { 2 | @apply mb-1 transition-colors duration-200 ease-in-out; 3 | } 4 | 5 | .primary-text:hover { 6 | @apply text-primary; 7 | } 8 | 9 | .secondary-text { 10 | @apply mb-1 text-sm opacity-75; 11 | } 12 | 13 | @layer utilities { 14 | .uppercase-first-letter::first-letter { 15 | @apply uppercase; 16 | } 17 | 18 | .lowercase-first-letter::first-letter { 19 | @apply lowercase; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /webui/src/client/styles/vendor/material-icons.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: "Material Icons"; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url("/fonts/icons/MaterialIcons-Regular.ttf") format("truetype"); 7 | } 8 | 9 | .material-icons { 10 | font-family: "Material Icons"; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | -webkit-font-feature-settings: "liga"; 22 | font-feature-settings: "liga"; 23 | -webkit-font-smoothing: antialiased; 24 | } 25 | -------------------------------------------------------------------------------- /webui/src/client/tests/testlang.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | async function loadLang(lang_id) { 3 | let language_module; 4 | const result = []; 5 | try { 6 | language_module = await import(`../lang/${lang_id}.mjs`); 7 | language_module = language_module.default; 8 | } catch { 9 | language_module = {}; 10 | } 11 | function parseObject(obj, root = "") { 12 | for (const [key, value] of Object.entries(obj)) { 13 | if (typeof value === "string") { 14 | result.push(root + key); 15 | } else { 16 | parseObject(value, root + key + "."); 17 | } 18 | } 19 | } 20 | parseObject(language_module); 21 | return result; 22 | } 23 | 24 | async function testLang(lang_id) { 25 | const baseLangFile = await loadLang("en"); 26 | const comparedLangFile = await loadLang(lang_id); 27 | 28 | if (comparedLangFile.length === 0) { 29 | console.log(`Language file ${lang_id} doesn't exist!`); 30 | return; 31 | } 32 | 33 | console.log("\nMissing Keys:"); 34 | baseLangFile.forEach((key) => { 35 | if (!comparedLangFile.includes(key)) console.log(key); 36 | }); 37 | 38 | console.log("\nExtra Keys:"); 39 | comparedLangFile.forEach((key) => { 40 | if (!baseLangFile.includes(key)) console.log(key); 41 | }); 42 | } 43 | 44 | (async () => { 45 | const args = process.argv.slice(2); 46 | if (args.length !== 1) { 47 | console.log("Usage:\nyarn testlang [COUNTRY_ID]\n"); 48 | return; 49 | } 50 | console.log(`Testing language file ${args[0]}`); 51 | await testLang(args[0]); 52 | console.log(""); 53 | })(); 54 | -------------------------------------------------------------------------------- /webui/src/client/tests/unit/utils/dates.test.ts: -------------------------------------------------------------------------------- 1 | import { checkNewRelease } from "../../../utils/dates"; 2 | 3 | describe("date utils", () => { 4 | describe("checkNewRelease", () => { 5 | test("returns a positive result checking today's date", () => { 6 | expect(checkNewRelease(new Date())).toBe(true); 7 | }); 8 | 9 | test("returns a negative result checking a week ago's date", () => { 10 | const dateToCheck = new Date(); 11 | dateToCheck.setDate(dateToCheck.getDate() - 7); 12 | 13 | expect(checkNewRelease(dateToCheck)).toBe(false); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /webui/src/client/tests/unit/utils/downloads.test.ts: -------------------------------------------------------------------------------- 1 | import { aggregateDownloadLinks } from "../../../utils/downloads"; 2 | 3 | describe("download utils", () => { 4 | describe("aggregateDownloadLinks", () => { 5 | test("merges links into a single string", () => { 6 | const release = { link: "abcde" }; 7 | const aggregated = aggregateDownloadLinks([release, release]); 8 | 9 | expect(aggregated).toBe("abcde;abcde"); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /webui/src/client/tests/unit/utils/texts.test.ts: -------------------------------------------------------------------------------- 1 | import { upperCaseFirstLowerCaseRest } from "../../../utils/texts"; 2 | 3 | describe("texts utils", () => { 4 | describe("upperCaseFirstLowerCaseRest", () => { 5 | test("converts a full uppercase string", () => { 6 | expect(upperCaseFirstLowerCaseRest("TEST STRING")).toBe("Test string"); 7 | }); 8 | 9 | test("converts a full lowercase string", () => { 10 | expect(upperCaseFirstLowerCaseRest("test string")).toBe("Test string"); 11 | }); 12 | 13 | test("converts a mixed string", () => { 14 | expect(upperCaseFirstLowerCaseRest("i wOn'T woRK")).toBe("I won't work"); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /webui/src/client/tests/unit/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isValidURL, 3 | convertDuration, 4 | convertDurationSeparated, 5 | } from "../../../utils/utils"; 6 | 7 | describe("utils utils (needs refactor)", () => { 8 | describe("isValidURL", () => { 9 | test("returns a positive result with all supported URLs", () => { 10 | expect(isValidURL("https://www.deezer.com")).toBe(true); 11 | expect(isValidURL("https://deezer.page.link")).toBe(true); 12 | expect(isValidURL("https://open.spotify.com")).toBe(true); 13 | expect(isValidURL("https://link.tospotify.com")).toBe(true); 14 | expect(isValidURL("spotify:something")).toBe(true); 15 | }); 16 | 17 | test("returns a negative result with a not supported URL", () => { 18 | expect(isValidURL("https://www.google.com")).toBe(false); 19 | }); 20 | }); 21 | 22 | describe("convertDuration", () => { 23 | test("converts seconds in the correct format", () => { 24 | expect(convertDuration(120)).toBe("2:00"); 25 | expect(convertDuration(60)).toBe("1:00"); 26 | expect(convertDuration(30)).toBe("0:30"); 27 | }); 28 | }); 29 | 30 | describe("convertDurationSeparated", () => { 31 | test("converts seconds in the correct format", () => { 32 | expect(convertDurationSeparated(120)).toStrictEqual([0, 2, 0]); 33 | expect(convertDurationSeparated(60)).toStrictEqual([0, 1, 0]); 34 | expect(convertDurationSeparated(30)).toStrictEqual([0, 0, 30]); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /webui/src/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.config.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "../../dist/public", 6 | 7 | "paths": { 8 | "@/*": ["./*"] 9 | }, 10 | 11 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 12 | "types": ["vitest/globals"], 13 | "noEmit": true, 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "bundler", 17 | 18 | // Required in Vue projects 19 | "jsx": "preserve", 20 | "jsxImportSource": "vue" 21 | }, 22 | "include": ["env.d.ts", "./**/*.ts", "./**/*.vue"] 23 | } 24 | -------------------------------------------------------------------------------- /webui/src/client/use/favorites.ts: -------------------------------------------------------------------------------- 1 | import i18n from "@/plugins/i18n"; 2 | import { pinia } from "@/stores"; 3 | import { useLoginStore } from "@/stores/login"; 4 | import { fetchData } from "@/utils/api-utils"; 5 | import { toast } from "@/utils/toasts"; 6 | import { computed, ref } from "vue"; 7 | 8 | const loginStore = useLoginStore(pinia); 9 | 10 | const favoriteArtists = ref([]); 11 | const favoriteAlbums = ref([]); 12 | const favoriteSpotifyPlaylists = ref([]); 13 | const favoritePlaylists = ref([]); 14 | const favoriteTracks = ref([]); 15 | const lovedTracksPlaylist = ref(""); 16 | const isLoggedWithSpotify = computed(() => loginStore.isLoggedWithSpotify); 17 | 18 | const isRefreshingFavorites = ref(false); 19 | 20 | const setAllFavorites = (data) => { 21 | const { tracks, albums, artists, playlists, lovedTracks } = data; 22 | 23 | isRefreshingFavorites.value = false; 24 | 25 | favoriteArtists.value = artists || []; 26 | favoriteAlbums.value = albums || []; 27 | favoritePlaylists.value = playlists || []; 28 | favoriteTracks.value = tracks || []; 29 | lovedTracksPlaylist.value = lovedTracks || []; 30 | }; 31 | 32 | const setSpotifyPlaylists = (response) => { 33 | if (response.error) { 34 | favoriteSpotifyPlaylists.value = []; 35 | switch (response.error) { 36 | case "spotifyNotEnabled": 37 | loginStore.setSpotifyStatus("disabled"); 38 | break; 39 | case "wrongSpotifyUsername": 40 | toast( 41 | i18n.global.t("toasts.wrongSpotifyUsername", { 42 | username: response.username, 43 | }), 44 | "person_off" 45 | ); 46 | break; 47 | default: 48 | break; 49 | } 50 | return; 51 | } 52 | 53 | favoriteSpotifyPlaylists.value = response || []; 54 | }; 55 | 56 | const refreshFavorites = async ({ isInitial = false }) => { 57 | if (!isInitial) { 58 | isRefreshingFavorites.value = true; 59 | } 60 | 61 | await loginStore.refreshSpotifyStatus(); 62 | 63 | fetchData("getUserFavorites").then(setAllFavorites).catch(console.error); 64 | 65 | if (isLoggedWithSpotify.value) { 66 | const spotifyUser = loginStore.spotifyUser.id; 67 | 68 | fetchData("getUserSpotifyPlaylists", { spotifyUser }) 69 | .then(setSpotifyPlaylists) 70 | .catch(console.error); 71 | } else { 72 | favoriteSpotifyPlaylists.value = []; 73 | } 74 | }; 75 | 76 | export const useFavorites = () => ({ 77 | favoriteArtists, 78 | favoriteAlbums, 79 | favoriteSpotifyPlaylists, 80 | favoritePlaylists, 81 | favoriteTracks, 82 | lovedTracksPlaylist, 83 | isRefreshingFavorites, 84 | refreshFavorites, 85 | }); 86 | -------------------------------------------------------------------------------- /webui/src/client/use/main-search.ts: -------------------------------------------------------------------------------- 1 | import { fetchData } from "@/utils/api-utils.js"; 2 | import { ref } from "vue"; 3 | 4 | interface SearchResult { 5 | QUERY?: string; 6 | AUTOCORRECT?: boolean; 7 | ORDER?: string[]; 8 | TOP_RESULT?: any[]; 9 | } 10 | 11 | const searchResult = ref({}); 12 | 13 | function performMainSearch(searchTerm) { 14 | fetchData("mainSearch", { term: searchTerm }).then((data) => { 15 | searchResult.value = data; 16 | }); 17 | } 18 | 19 | export function useMainSearch() { 20 | return { 21 | searchResult, 22 | performMainSearch, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /webui/src/client/use/online.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | const isOnline = ref(navigator.onLine); 4 | 5 | window.addEventListener("online", () => { 6 | isOnline.value = true; 7 | }); 8 | 9 | window.addEventListener("offline", () => { 10 | isOnline.value = false; 11 | }); 12 | 13 | export const useOnline = () => ({ isOnline }); 14 | -------------------------------------------------------------------------------- /webui/src/client/use/search.ts: -------------------------------------------------------------------------------- 1 | import { fetchData } from "@/utils/api-utils"; 2 | import { ref } from "vue"; 3 | 4 | interface Result { 5 | next?: string; 6 | total?: number; 7 | type?: string; 8 | data?: any[]; 9 | error?: string; 10 | } 11 | 12 | const result = ref({}); 13 | 14 | function performSearch({ term, type, start = 0, nb = 30 }) { 15 | fetchData("search", { 16 | term, 17 | type, 18 | start, 19 | nb, 20 | }).then((data) => { 21 | result.value = data; 22 | }); 23 | } 24 | 25 | export function useSearch() { 26 | return { 27 | result, 28 | performSearch, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /webui/src/client/use/theme.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch } from "vue"; 2 | 3 | const THEMES = { 4 | dark: "dark", 5 | light: "light", 6 | purple: "purple", 7 | }; 8 | 9 | const initialTheme = 10 | localStorage.getItem("selectedTheme") || 11 | document.documentElement.dataset.theme || 12 | THEMES.dark; 13 | const currentTheme = ref(initialTheme); 14 | 15 | watch(currentTheme, (newTheme, oldTheme) => { 16 | // No operation needed 17 | if (oldTheme === newTheme) return; 18 | 19 | localStorage.setItem("selectedTheme", newTheme); 20 | document.documentElement.dataset.theme = newTheme; 21 | 22 | animateAllElements(); 23 | }); 24 | 25 | function animateAllElements() { 26 | // Animating everything to have a smoother theme switch 27 | const allElements = document.querySelectorAll("*"); 28 | 29 | allElements.forEach((el) => { 30 | el.classList.add("changing-theme"); 31 | }); 32 | 33 | document.documentElement.addEventListener( 34 | "transitionend", 35 | function transitionHandler() { 36 | allElements.forEach((el) => { 37 | el.classList.remove("changing-theme"); 38 | }); 39 | 40 | document.documentElement.removeEventListener( 41 | "transitionend", 42 | transitionHandler 43 | ); 44 | } 45 | ); 46 | } 47 | 48 | export const useTheme = () => ({ 49 | THEMES, 50 | currentTheme, 51 | }); 52 | -------------------------------------------------------------------------------- /webui/src/client/utils/adjust-volume.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/7451508/html5-audio-playback-with-fade-in-and-fade-out#answer-13149848 2 | export function adjustVolume( 3 | element: HTMLAudioElement, 4 | newVolume: number, 5 | { duration = 1000, easing = swing, interval = 13 } = {} 6 | ) { 7 | const originalVolume = element.volume; 8 | const delta = newVolume - originalVolume; 9 | 10 | if (!delta || !duration || !easing || !interval) { 11 | element.volume = newVolume; 12 | return Promise.resolve(); 13 | } 14 | 15 | const ticks = Math.floor(duration / interval); 16 | let tick = 1; 17 | 18 | return new Promise((resolve) => { 19 | const timer = setInterval(() => { 20 | element.volume = originalVolume + easing(tick / ticks) * delta; 21 | if (++tick === ticks) { 22 | clearInterval(timer); 23 | resolve(); 24 | } 25 | }, interval); 26 | }); 27 | } 28 | 29 | export function swing(p: number) { 30 | return 0.5 - Math.cos(p * Math.PI) / 2; 31 | } 32 | -------------------------------------------------------------------------------- /webui/src/client/utils/api-utils.ts: -------------------------------------------------------------------------------- 1 | export function fetchData( 2 | key: string, 3 | data: Record = {}, 4 | method = "GET" 5 | ) { 6 | const url = new URL(`${window.location.origin}${location.base}api/${key}`); 7 | 8 | Object.keys(data).forEach((key) => { 9 | url.searchParams.append(key, data[key]); 10 | }); 11 | 12 | return fetch(url.href, { method }) 13 | .then((response) => response.json()) 14 | .catch((error) => { 15 | console.error( 16 | "There has been a problem with your fetch operation:", 17 | error 18 | ); 19 | return Promise.reject(error); 20 | }); 21 | } 22 | 23 | export function sendToServer(key: string, data: Record) { 24 | const url = new URL(`${window.location.origin}${location.base}api/${key}`); 25 | 26 | Object.keys(data).forEach((key) => { 27 | url.searchParams.append(key, data[key]); 28 | }); 29 | 30 | fetch(url.href).catch((error) => { 31 | console.error("There has been a problem with your fetch operation:", error); 32 | }); 33 | } 34 | 35 | export function postToServer(endpoint: string, data?: Record) { 36 | const url = new URL( 37 | `${window.location.origin}${location.base}api/${endpoint}` 38 | ); 39 | 40 | return fetch(url, { 41 | body: JSON.stringify(data), 42 | headers: { 43 | "Content-Type": "application/json", 44 | }, 45 | method: "POST", 46 | }) 47 | .then((response) => { 48 | if (!response.ok) { 49 | throw new Error("Network response was not ok"); 50 | } 51 | return response.json(); 52 | }) 53 | .catch((error) => { 54 | console.error( 55 | "There has been a problem with your fetch operation:", 56 | error 57 | ); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /webui/src/client/utils/dates.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The passed date is less than 3 days distant from today, therefore it's considered a new release, if referring to a track or album. 3 | */ 4 | export function checkNewRelease(dateToCheck: Date): boolean { 5 | const now = new Date(); 6 | now.setHours(0, 0, 0, 0); 7 | 8 | dateToCheck = new Date(dateToCheck); 9 | dateToCheck.setDate(dateToCheck.getDate() + 3); 10 | 11 | return now.getTime() <= dateToCheck.getTime(); 12 | } 13 | -------------------------------------------------------------------------------- /webui/src/client/utils/downloads.ts: -------------------------------------------------------------------------------- 1 | import { postToServer } from "@/utils/api-utils"; 2 | 3 | export function sendAddToQueue(url: string, bitrate?: number) { 4 | if (!url) throw new Error("No URL given to sendAddToQueue function!"); 5 | 6 | postToServer("addToQueue", { url, bitrate }); 7 | } 8 | 9 | export function aggregateDownloadLinks(releases: { link: string }[]): string { 10 | const links = releases.map((release) => release.link); 11 | 12 | return links.join(";"); 13 | } 14 | -------------------------------------------------------------------------------- /webui/src/client/utils/emitter.ts: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | 3 | export const emitter = mitt(); 4 | -------------------------------------------------------------------------------- /webui/src/client/utils/flags.ts: -------------------------------------------------------------------------------- 1 | import it from "flag-icon-css/flags/4x3/it.svg?url"; 2 | import gb from "flag-icon-css/flags/4x3/gb.svg?url"; 3 | import es from "flag-icon-css/flags/4x3/es.svg?url"; 4 | import de from "flag-icon-css/flags/4x3/de.svg?url"; 5 | import fr from "flag-icon-css/flags/4x3/fr.svg?url"; 6 | import id from "flag-icon-css/flags/4x3/id.svg?url"; 7 | import pt from "flag-icon-css/flags/4x3/pt.svg?url"; 8 | import br from "flag-icon-css/flags/4x3/br.svg?url"; 9 | import ru from "flag-icon-css/flags/4x3/ru.svg?url"; 10 | import tr from "flag-icon-css/flags/4x3/tr.svg?url"; 11 | import vn from "flag-icon-css/flags/4x3/vn.svg?url"; 12 | import hr from "flag-icon-css/flags/4x3/hr.svg?url"; 13 | import ko from "flag-icon-css/flags/4x3/kr.svg?url"; 14 | import ph from "flag-icon-css/flags/4x3/ph.svg?url"; 15 | import tw from "flag-icon-css/flags/4x3/tw.svg?url"; 16 | import pl from "flag-icon-css/flags/4x3/pl.svg?url"; 17 | import rs from "flag-icon-css/flags/4x3/rs.svg?url"; 18 | import gr from "flag-icon-css/flags/4x3/gr.svg?url"; 19 | import th from "flag-icon-css/flags/4x3/th.svg?url"; 20 | import ar from "@/assets/ar.svg?url"; 21 | 22 | export const flags = { 23 | it: { name: "Italiano", eng: "Italian", flag: it }, 24 | en: { name: "English", eng: "English", flag: gb }, 25 | es: { name: "Español", eng: "Spanish", flag: es }, 26 | de: { name: "Deutsch", eng: "German", flag: de }, 27 | fr: { name: "Français", eng: "French", flag: fr }, 28 | id: { name: "Bahasa Indonesia", eng: "Indonesian", flag: id }, 29 | pt: { name: "Português", eng: "Portuguese", flag: pt }, 30 | pt_br: { 31 | name: "Português Brasileiro", 32 | eng: "Portuguese of Brasil", 33 | flag: br, 34 | }, 35 | ru: { name: "Русский", eng: "Russian", flag: ru }, 36 | tr: { name: "Türkçe", eng: "Turkish", flag: tr }, 37 | vi: { name: "Tiếng Việt", eng: "Vietnamese", flag: vn }, 38 | hr: { name: "Hrvatski Jezik", eng: "Croatian", flag: hr }, 39 | ar: { name: "العربية", eng: "Arabic", flag: ar }, 40 | ko: { name: "한국어", eng: "Korean", flag: ko }, 41 | fil: { name: "Wikang Filipino", eng: "Filipino", flag: ph }, 42 | zh_tw: { name: "漢語", eng: "Chinese", flag: tw }, 43 | pl: { name: "Polszczyzna", eng: "Polish", flag: pl }, 44 | el: { name: "ελληνικά", eng: "Greek", flag: gr }, 45 | sr: { name: "српски језик", eng: "Serbian", flag: rs }, 46 | th: { name: "ไทย", eng: "Thai", flag: th }, 47 | }; 48 | -------------------------------------------------------------------------------- /webui/src/client/utils/forms.ts: -------------------------------------------------------------------------------- 1 | export const getFormItem = (formEl: HTMLFormElement) => (item: string) => { 2 | const element = formEl.elements.namedItem(item); 3 | 4 | return { 5 | [item]: 6 | element instanceof HTMLInputElement || 7 | element instanceof HTMLTextAreaElement || 8 | element instanceof HTMLSelectElement || 9 | element instanceof RadioNodeList 10 | ? element.value 11 | : element, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /webui/src/client/utils/socket.ts: -------------------------------------------------------------------------------- 1 | class CustomSocket extends WebSocket { 2 | listeners: Record) => any>; 3 | 4 | constructor(args: string | URL) { 5 | super(args); 6 | this.listeners = {}; 7 | } 8 | 9 | emit(key: string, data?: any) { 10 | if (this.readyState !== WebSocket.OPEN) return false; 11 | 12 | this.send(JSON.stringify({ key, data })); 13 | } 14 | 15 | on(key: string, cb: (ev: any) => any) { 16 | if (!Object.keys(this.listeners).includes(key)) { 17 | this.listeners[key] = cb; 18 | 19 | this.addEventListener("message", (event) => { 20 | const messageData = JSON.parse(event.data); 21 | 22 | if (messageData.key === key) { 23 | cb(messageData.data); 24 | } 25 | }); 26 | } 27 | } 28 | 29 | off(key: string) { 30 | if (Object.keys(this.listeners).includes(key)) { 31 | this.removeEventListener("message", this.listeners[key]); 32 | delete this.listeners[key]; 33 | } 34 | } 35 | } 36 | 37 | export const socket = new CustomSocket( 38 | (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/" 39 | ); 40 | -------------------------------------------------------------------------------- /webui/src/client/utils/texts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} text 3 | */ 4 | export const upperCaseFirstLowerCaseRest = (text) => 5 | text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); 6 | -------------------------------------------------------------------------------- /webui/src/server/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "express-session" { 2 | export interface SessionData { 3 | dz: Deezer; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /webui/src/server/helpers/errors.ts: -------------------------------------------------------------------------------- 1 | import { TrackFormats } from "deezer-sdk"; 2 | 3 | const bitrateLabels = { 4 | [TrackFormats.MP4_RA3]: "360 HQ", 5 | [TrackFormats.MP4_RA2]: "360 MQ", 6 | [TrackFormats.MP4_RA1]: "360 LQ", 7 | [TrackFormats.FLAC]: "FLAC", 8 | [TrackFormats.MP3_320]: "320kbps", 9 | [TrackFormats.MP3_128]: "128kbps", 10 | [TrackFormats.DEFAULT]: "128kbps", 11 | [TrackFormats.LOCAL]: "MP3", 12 | }; 13 | 14 | export class BadRequestError extends Error { 15 | constructor() { 16 | super(); 17 | this.message = "Bad request!"; 18 | } 19 | } 20 | 21 | export class QueueError extends Error { 22 | constructor(message: string) { 23 | super(message); 24 | this.name = "QueueError"; 25 | } 26 | } 27 | 28 | export class AlreadyInQueue extends QueueError { 29 | item: any; 30 | silent: boolean; 31 | constructor(dwObj: any, silent: boolean) { 32 | super(`${dwObj.artist} - ${dwObj.title} is already in queue.`); 33 | this.name = "AlreadyInQueue"; 34 | this.item = dwObj; 35 | this.silent = silent; 36 | } 37 | } 38 | 39 | export class NotLoggedIn extends QueueError { 40 | constructor() { 41 | super(`You must be logged in to start a download.`); 42 | this.name = "NotLoggedIn"; 43 | } 44 | } 45 | 46 | export class CantStream extends QueueError { 47 | bitrate: number; 48 | constructor(bitrate: number) { 49 | super(`Your account can't stream at ${bitrateLabels[bitrate]}.`); 50 | this.name = "CantStream"; 51 | this.bitrate = bitrate; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /webui/src/server/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | import { formatDate } from "date-fns"; 2 | import fs from "fs"; 3 | import os from "os"; 4 | import { join as joinPath } from "path"; 5 | import { createLogger, format, transports } from "winston"; 6 | import * as deemix from "deemix"; 7 | 8 | const { combine, timestamp, errors, colorize, printf } = format; 9 | 10 | const logFolder: string = joinPath(deemix.utils.getConfigFolder(), "logs"); 11 | 12 | const logFilename = joinPath( 13 | logFolder, 14 | `${formatDate(new Date(), "yyyy-MM-dd-hh.mm.ss")}.log` 15 | ); 16 | 17 | const logFormat = printf((error) => { 18 | const { level, message } = error; 19 | 20 | return `[${level}] ${message}`; 21 | }); 22 | 23 | export const logger = createLogger({ 24 | format: combine(errors({ stack: true }), timestamp(), logFormat), 25 | transports: [ 26 | new transports.File({ 27 | handleExceptions: true, 28 | handleRejections: true, 29 | format: combine(errors({ stack: true }), timestamp(), logFormat), 30 | filename: logFilename, 31 | }), 32 | new transports.Console({ 33 | handleExceptions: true, 34 | handleRejections: true, 35 | format: combine( 36 | errors({ stack: true }), 37 | colorize(), 38 | timestamp(), 39 | logFormat 40 | ), 41 | }), 42 | ], 43 | }); 44 | 45 | export function removeOldLogs(logFilesNumber: number) { 46 | if (!fs.existsSync(logFolder)) fs.mkdirSync(logFolder, { recursive: true }); 47 | fs.appendFileSync( 48 | logFilename, 49 | `${os.platform()} - ${os.type()} ${os.release()} ${os.arch()}\n\n` 50 | ); 51 | const files = fs.readdirSync(logFolder); 52 | const logs: Array = []; 53 | files.forEach(function (file) { 54 | logs.push(file.substring(0, file.length - 4)); 55 | }); 56 | logs.sort(); 57 | if (logs.length > logFilesNumber) { 58 | for (let i = 0; i < logs.length - logFilesNumber; i++) { 59 | fs.unlinkSync(joinPath(logFolder, logs[i] + ".log")); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /webui/src/server/helpers/loginStorage.ts: -------------------------------------------------------------------------------- 1 | import * as deemix from "deemix"; 2 | import fs from "fs"; 3 | import type { LoginFile } from "../types.js"; 4 | 5 | const configFolder = deemix.utils.getConfigFolder(); 6 | 7 | const DEFAULTS: LoginFile = { 8 | accessToken: null, 9 | arl: null, 10 | }; 11 | 12 | let loginData: LoginFile = { 13 | accessToken: null, 14 | arl: null, 15 | }; 16 | 17 | export function loadLoginCredentials() { 18 | if (!fs.existsSync(configFolder)) fs.mkdirSync(configFolder); 19 | if (!fs.existsSync(configFolder + "login.json")) resetLoginCredentials(); 20 | 21 | try { 22 | loginData = JSON.parse( 23 | fs.readFileSync(configFolder + "login.json").toString() 24 | ); 25 | } catch (e: any) { 26 | if (e.name === "SyntaxError") resetLoginCredentials(); 27 | } 28 | } 29 | 30 | export function getLoginCredentials(): LoginFile { 31 | if (!loginData.arl) loadLoginCredentials(); 32 | return loginData; 33 | } 34 | 35 | export function saveLoginCredentials(newLogin: LoginFile) { 36 | if (newLogin.arl) loginData.arl = newLogin.arl; 37 | if (newLogin.accessToken) loginData.accessToken = newLogin.accessToken; 38 | if (!fs.existsSync(configFolder)) fs.mkdirSync(configFolder); 39 | fs.writeFileSync( 40 | configFolder + "login.json", 41 | JSON.stringify(loginData, null, 2) 42 | ); 43 | } 44 | 45 | export function resetLoginCredentials() { 46 | if (!fs.existsSync(configFolder)) fs.mkdirSync(configFolder); 47 | fs.writeFileSync( 48 | configFolder + "login.json", 49 | JSON.stringify(DEFAULTS, null, 2) 50 | ); 51 | loginData = JSON.parse(JSON.stringify(DEFAULTS)); 52 | } 53 | -------------------------------------------------------------------------------- /webui/src/server/helpers/port.ts: -------------------------------------------------------------------------------- 1 | import { type Port } from "../types.js"; 2 | 3 | /** 4 | * Normalize a port into a number, string, or false. 5 | * 6 | * @since 0.0.0 7 | */ 8 | export function normalizePort(portString: string): Port { 9 | const port = parseInt(portString, 10); 10 | 11 | if (isNaN(port)) { 12 | // named pipe 13 | return portString; 14 | } 15 | 16 | if (port >= 0) { 17 | // port number 18 | return port; 19 | } 20 | 21 | return false; 22 | } 23 | -------------------------------------------------------------------------------- /webui/src/server/helpers/primitive-checks.ts: -------------------------------------------------------------------------------- 1 | export const isObjectEmpy = (obj: any) => Object.keys(obj).length === 0; 2 | -------------------------------------------------------------------------------- /webui/src/server/helpers/server-callbacks.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import { logger } from "./logger.js"; 3 | 4 | /** 5 | * Event listener for HTTP server "error" event. 6 | * 7 | * @since 0.0.0 8 | */ 9 | export function getErrorCb(port: number | string | boolean) { 10 | return (error: any) => { 11 | if (error.syscall !== "listen") { 12 | throw error; 13 | } 14 | 15 | const bind = typeof port === "string" ? "Pipe " + port : "Port " + port; 16 | 17 | // handle specific listen errors with friendly messages 18 | switch (error.code) { 19 | case "EACCES": 20 | logger.error(bind + " requires elevated privileges"); 21 | process.exit(1); 22 | break; 23 | case "EADDRINUSE": 24 | logger.error(bind + " is already in use"); 25 | process.exit(1); 26 | break; 27 | default: 28 | throw error; 29 | } 30 | }; 31 | } 32 | 33 | /** 34 | * Event listener for HTTP server "listening" event. 35 | * 36 | * @since 0.0.0 37 | */ 38 | export function getListeningCb(server: http.Server) { 39 | return () => { 40 | const addr = server.address(); 41 | 42 | if (addr) { 43 | const ip = typeof addr === "string" ? "pipe " + addr : addr.address; 44 | const port = typeof addr === "string" ? "pipe " + addr : addr.port; 45 | 46 | logger.info(`Listening on ${ip}:${port}`); 47 | } 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /webui/src/server/helpers/versions.ts: -------------------------------------------------------------------------------- 1 | import deemixPackage from "deemix/package.json" with { type: "json" }; 2 | import { version } from "./web-version.js"; 3 | 4 | export const DEEMIX_PACKAGE_VERSION = deemixPackage.version; 5 | export const WEBUI_PACKAGE_VERSION = version; 6 | export const GUI_VERSION = process.env.GUI_VERSION || undefined; 7 | -------------------------------------------------------------------------------- /webui/src/server/helpers/web-version.ts: -------------------------------------------------------------------------------- 1 | // This is a stub. It will be replaced at build time. 2 | export const version = "development"; 3 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/delete/index.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | export default [] as ApiHandler[]; 4 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/albumSearch.test.ts: -------------------------------------------------------------------------------- 1 | import { appSendGet } from "@/tests/utils.js"; 2 | 3 | describe("albumSearch requests", () => { 4 | test("should respond 200 to calls with term", async () => { 5 | const batchCalls = [ 6 | "/api/album-search/?term=eminem", 7 | "/api/album-search/?term=eminem?start=10", 8 | "/api/album-search/?term=eminem?ack=aa", 9 | "/api/album-search/?term=eminem?ack=aa?start=10", 10 | "/api/album-search/?term=eminem?ack=aa?start=10?nb=34", 11 | ]; 12 | 13 | for (const uri of batchCalls) { 14 | appSendGet(uri).expect(200); 15 | } 16 | }); 17 | 18 | test("should respond 400 to calls without term", async () => { 19 | const batchCalls = [ 20 | "/api/album-search/", 21 | "/api/album-search/?start=10", 22 | "/api/album-search/?ack=aa", 23 | "/api/album-search/?ack=aa?start=10", 24 | "/api/album-search/?ack=aa?start=10?nb=34", 25 | ]; 26 | 27 | for (const uri of batchCalls) { 28 | appSendGet(uri).expect(400); 29 | } 30 | }); 31 | 32 | test("should respond the desired search result", async () => { 33 | appSendGet("/api/album-search/?term=eminem") 34 | .expect(200) 35 | .expect((res) => { 36 | expect(res.body.data.length).not.toBe(0); 37 | }); 38 | }); 39 | 40 | it.skip("should respond the desired search result with a start parameter", async () => { 41 | appSendGet("/api/album-search/?term=eminem?start=10") 42 | .expect(200) 43 | .expect((res) => { 44 | expect(res.body.data.length).not.toBe(0); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/albumSearch.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import type { RequestHandler } from "express"; 3 | import { sessionDZ } from "@/deemixApp.js"; 4 | import type { ApiHandler } from "@/types.js"; 5 | 6 | export interface RawAlbumQuery { 7 | term: string; 8 | start?: string; 9 | nb?: string; 10 | } 11 | 12 | export interface AlbumSearchParams extends Omit { 13 | start: number; 14 | nb: number; 15 | } 16 | 17 | export interface AlbumResponse { 18 | data: any[]; 19 | total: number; 20 | } 21 | 22 | const path: ApiHandler["path"] = "/album-search/"; 23 | 24 | const handler: RequestHandler = async ( 25 | req, 26 | res 27 | ) => { 28 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 29 | const dz = sessionDZ[req.session.id]; 30 | 31 | if (!req.query) { 32 | res.status(400).send(); 33 | } 34 | 35 | const { term, start, nb } = parseQuery(req.query); 36 | 37 | if (!term || term.trim() === "") { 38 | res.status(400).send(); 39 | } 40 | 41 | const results = await dz.gw.search_music(term, "ALBUM", { 42 | index: start, 43 | limit: nb, 44 | }); 45 | 46 | const albums = await Promise.all( 47 | results.data.map((c: any) => getAlbumDetails(dz, c.ALB_ID)) 48 | ); 49 | 50 | const output: AlbumResponse = { 51 | data: albums, 52 | total: albums.length, 53 | }; 54 | 55 | res.send(output); 56 | }; 57 | 58 | export const apiHandler = { path, handler }; 59 | 60 | function parseQuery(query: RawAlbumQuery): AlbumSearchParams { 61 | let startingPoint = 0; 62 | 63 | if (typeof query.start !== "undefined") { 64 | startingPoint = parseInt(query.start); 65 | } 66 | 67 | let newNb = 30; 68 | 69 | if (typeof query.nb !== "undefined") { 70 | newNb = parseInt(query.nb); 71 | } 72 | 73 | return { 74 | term: query.term, 75 | start: startingPoint, 76 | nb: newNb, 77 | }; 78 | } 79 | 80 | export async function getAlbumDetails( 81 | dz: Deezer, 82 | albumId: string 83 | ): Promise { 84 | const result = await dz.gw.get_album_page(albumId); 85 | const output = result.DATA; 86 | 87 | let duration = 0; 88 | result.SONGS.data.forEach((s: any) => { 89 | if ("DURATION" in s) { 90 | duration += parseInt(s.DURATION); 91 | } 92 | }); 93 | 94 | output.DURATION = duration; 95 | output.NUMBER_TRACK = result.SONGS.total; 96 | output.LINK = `https://deezer.com/album/${output.ALB_ID}`; 97 | 98 | return output; 99 | } 100 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/analyzeLink.test.ts: -------------------------------------------------------------------------------- 1 | import { appSendGet } from "@/tests/utils.js"; 2 | 3 | describe("analyzeLink requests", () => { 4 | test("should respond 200 to calls with supported term", async () => { 5 | appSendGet( 6 | "/api/analyzeLink/?term=https://www.deezer.com/en/album/100896762" 7 | ).expect(200); 8 | }); 9 | 10 | test("should respond with an error to calls with not supported term", async () => { 11 | appSendGet( 12 | "/api/analyzeLink/?term=https://www.deezer.com/en/artist/15166511" 13 | ) 14 | .expect(400) 15 | .expect((res) => { 16 | expect(res.body.errorMessage).toBe("Not supported"); 17 | }); 18 | }); 19 | 20 | test("should respond album analyzed data", async () => { 21 | appSendGet( 22 | "/api/analyzeLink/?term=https://www.deezer.com/en/album/100896762" 23 | ).expect((res) => { 24 | expect(res.body.type).toBe("album"); 25 | expect(res.body.artist.name).toBe("Lil Nas X"); 26 | }); 27 | }); 28 | 29 | test("should respond track analyzed data", async () => { 30 | appSendGet( 31 | "/api/analyzeLink/?term=https://www.deezer.com/en/track/1283264142" 32 | ).expect((res) => { 33 | expect(res.body.type).toBe("track"); 34 | expect(res.body.artist.name).toBe("Lil Nas X"); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/analyzeLink.ts: -------------------------------------------------------------------------------- 1 | import * as deemix from "deemix"; 2 | import { Deezer } from "deezer-sdk"; 3 | import type { RequestHandler } from "express"; 4 | import { sessionDZ } from "../../../deemixApp.js"; 5 | import type { 6 | ApiHandler, 7 | GetAlbumResponse, 8 | GetTrackResponse, 9 | } from "../../../types.js"; 10 | 11 | export interface AnalyzeQuery { 12 | term?: string; 13 | } 14 | 15 | type ResBody = GetAlbumResponse | GetTrackResponse; 16 | 17 | const path: ApiHandler["path"] = "/analyzeLink"; 18 | 19 | const handler: RequestHandler = async ( 20 | req, 21 | res 22 | ) => { 23 | try { 24 | if (!req.query || !req.query.term) { 25 | res 26 | .status(400) 27 | .send({ errorMessage: "No term specified", errorCode: "AL01" }); 28 | } 29 | 30 | const { term: linkToAnalyze } = req.query; 31 | const [, linkType, linkId] = await deemix.parseLink(linkToAnalyze); 32 | const isTrackOrAlbum = ["track", "album"].includes(linkType); 33 | 34 | if (isTrackOrAlbum) { 35 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 36 | const dz = sessionDZ[req.session.id]; 37 | const apiMethod = linkType === "track" ? "get_track" : "get_album"; 38 | const resBody: ResBody = await dz.api[apiMethod](linkId); 39 | 40 | res.status(200).send(resBody); 41 | } 42 | 43 | res.status(400).send({ errorMessage: "Not supported", errorCode: "AL02" }); 44 | } catch (error) { 45 | res.status(500).send({ 46 | errorMessage: "The server had a problem. Please try again", 47 | errorObject: error, 48 | errorCode: "AL03", 49 | }); 50 | } 51 | }; 52 | 53 | const apiHandler: ApiHandler = { path, handler }; 54 | 55 | export default apiHandler; 56 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/checkForUpdates.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "@/types.js"; 2 | 3 | const path: ApiHandler["path"] = "/checkForUpdates"; 4 | 5 | const handler: ApiHandler["handler"] = async (req, res) => { 6 | const deemix = req.app.get("deemix"); 7 | const latestVersion = await deemix.getLatestVersion(); 8 | 9 | res.send({ 10 | latestVersion, 11 | updateAvailable: deemix.isUpdateAvailable(), 12 | }); 13 | }; 14 | 15 | const apiHandler: ApiHandler = { path, handler }; 16 | 17 | export default apiHandler; 18 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/connect.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "@/deemixApp.js"; 3 | import { logger } from "@/helpers/logger.js"; 4 | import { getLoginCredentials } from "@/helpers/loginStorage.js"; 5 | import { type ApiHandler } from "@/types.js"; 6 | import { 7 | DEEMIX_PACKAGE_VERSION, 8 | GUI_VERSION, 9 | WEBUI_PACKAGE_VERSION, 10 | } from "@/helpers/versions.js"; 11 | 12 | const path: ApiHandler["path"] = "/connect"; 13 | let update: { 14 | webuiVersion: string; 15 | deemixVersion: string; 16 | guiVersion?: string; 17 | } | null = null; 18 | 19 | const handler: ApiHandler["handler"] = async (req, res) => { 20 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 21 | const dz = sessionDZ[req.session.id]; 22 | const deemix = req.app.get("deemix"); 23 | const isSingleUser = req.app.get("isSingleUser"); 24 | 25 | if (!update) { 26 | logger.info(`webui version ${WEBUI_PACKAGE_VERSION}`); 27 | logger.info(`deemix version ${DEEMIX_PACKAGE_VERSION}`); 28 | if (GUI_VERSION) logger.info(`gui version ${GUI_VERSION}`); 29 | 30 | update = { 31 | webuiVersion: WEBUI_PACKAGE_VERSION, 32 | guiVersion: GUI_VERSION, 33 | deemixVersion: DEEMIX_PACKAGE_VERSION, 34 | }; 35 | } 36 | 37 | const result = { 38 | update, 39 | autologin: !dz.loggedIn, 40 | currentUser: dz.currentUser, 41 | deezerAvailable: await deemix.isDeezerAvailable(), 42 | spotifyEnabled: deemix.plugins.spotify.enabled, 43 | settingsData: deemix.getSettings(), 44 | }; 45 | 46 | if (isSingleUser && result.autologin) 47 | result.singleUser = getLoginCredentials(); 48 | 49 | if (result.settingsData.settings.autoCheckForUpdates) 50 | result.checkForUpdates = true; 51 | 52 | const queue = deemix.getQueue(); 53 | 54 | if (Object.keys(queue.queue).length > 0) { 55 | result.queue = queue; 56 | } 57 | 58 | res.send(result); 59 | }; 60 | 61 | const apiHandler: ApiHandler = { path, handler }; 62 | 63 | export default apiHandler; 64 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getChartTracks.ts: -------------------------------------------------------------------------------- 1 | import { sessionDZ } from "@/deemixApp.js"; 2 | import { BadRequestError } from "@/helpers/errors.js"; 3 | import { logger } from "@/helpers/logger.js"; 4 | import { isObjectEmpy } from "@/helpers/primitive-checks.js"; 5 | import type { ApiHandler } from "@/types.js"; 6 | import { Deezer } from "deezer-sdk"; 7 | import type { RequestHandler } from "express"; 8 | 9 | export interface RawChartTracksQuery { 10 | id: string; 11 | index?: number; 12 | limit?: number; 13 | } 14 | 15 | const path: ApiHandler["path"] = "/getChartTracks"; 16 | 17 | const handler: RequestHandler = async ( 18 | req, 19 | res, 20 | next 21 | ) => { 22 | try { 23 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 24 | const dz = sessionDZ[req.session.id]; 25 | 26 | if (isObjectEmpy(req.query) || !req.query.id) { 27 | throw new BadRequestError(); 28 | } 29 | 30 | const playlistId = req.query.id; 31 | const index = req.query.index; 32 | const limit = req.query.limit; 33 | 34 | const response = await dz.api.get_playlist_tracks(playlistId, { 35 | index, 36 | limit, 37 | }); 38 | res.status(200).send(response); 39 | } catch (error) { 40 | if (error instanceof BadRequestError) { 41 | logger.error(error.message); 42 | res.status(400).send(); 43 | return next(); 44 | } 45 | } 46 | }; 47 | 48 | const apiHandler: ApiHandler = { path, handler }; 49 | 50 | export default apiHandler; 51 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getCharts.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | 5 | const path: ApiHandler["path"] = "/getCharts"; 6 | 7 | let chartsCache: any; 8 | 9 | const handler: ApiHandler["handler"] = async (req, res) => { 10 | if (!chartsCache) { 11 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 12 | const dz = sessionDZ[req.session.id]; 13 | 14 | const chartsData = await dz.api.get_countries_charts(); 15 | const countries: any[] = []; 16 | chartsData.forEach((country: any) => { 17 | countries.push({ 18 | title: country.title.replace("Top ", ""), 19 | id: country.id, 20 | picture_small: country.picture_small, 21 | picture_medium: country.picture_medium, 22 | picture_big: country.picture_big, 23 | }); 24 | }); 25 | chartsCache = { data: countries }; 26 | } 27 | res.send(chartsCache); 28 | }; 29 | 30 | const apiHandler: ApiHandler = { path, handler }; 31 | 32 | export default apiHandler; 33 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getHome.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | 5 | const path: ApiHandler["path"] = "/getHome"; 6 | 7 | let homeCache: any; 8 | 9 | const handler: ApiHandler["handler"] = async (req, res) => { 10 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 11 | const dz = sessionDZ[req.session.id]; 12 | 13 | if (!homeCache) { 14 | homeCache = await dz.api.get_chart(0, { limit: 30 }); 15 | } 16 | res.send(homeCache); 17 | }; 18 | 19 | const apiHandler: ApiHandler = { path, handler }; 20 | 21 | export default apiHandler; 22 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getQueue.ts: -------------------------------------------------------------------------------- 1 | // import { Deezer } from 'deezer-sdk' 2 | import { type ApiHandler } from "../../../types.js"; 3 | 4 | const path: ApiHandler["path"] = "/getQueue"; 5 | 6 | // let homeCache: any 7 | 8 | const handler: ApiHandler["handler"] = (req, res) => { 9 | const deemix = req.app.get("deemix"); 10 | const result: any = deemix.getQueue(); 11 | res.send(result); 12 | }; 13 | 14 | const apiHandler: ApiHandler = { path, handler }; 15 | 16 | export default apiHandler; 17 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getSettings.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | const path: ApiHandler["path"] = "/getSettings"; 4 | 5 | const handler: ApiHandler["handler"] = (req, res) => { 6 | const deemix = req.app.get("deemix"); 7 | res.send(deemix.getSettings()); 8 | }; 9 | 10 | const apiHandler: ApiHandler = { path, handler }; 11 | 12 | export default apiHandler; 13 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getUserAlbums.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "@/deemixApp.js"; 3 | import { type ApiHandler } from "@/types.js"; 4 | 5 | const path: ApiHandler["path"] = "/getUserAlbums"; 6 | 7 | const handler: ApiHandler["handler"] = async (req, res) => { 8 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 9 | const dz = sessionDZ[req.session.id]; 10 | let data; 11 | 12 | if (dz.loggedIn) { 13 | const userID = dz.currentUser.id; 14 | data = await dz.gw.get_user_albums(userID, { limit: -1 }); 15 | } else { 16 | data = { error: "notLoggedIn" }; 17 | } 18 | res.send(data); 19 | }; 20 | 21 | const apiHandler: ApiHandler = { path, handler }; 22 | 23 | export default apiHandler; 24 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getUserArtists.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | 5 | const path: ApiHandler["path"] = "/getUserArtists"; 6 | 7 | const handler: ApiHandler["handler"] = async (req, res) => { 8 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 9 | const dz = sessionDZ[req.session.id]; 10 | let data; 11 | 12 | if (dz.loggedIn) { 13 | const userID = dz.currentUser.id; 14 | data = await dz.gw.get_user_artists(userID, { limit: -1 }); 15 | } else { 16 | data = { error: "notLoggedIn" }; 17 | } 18 | res.send(data); 19 | }; 20 | 21 | const apiHandler: ApiHandler = { path, handler }; 22 | 23 | export default apiHandler; 24 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getUserFavorites.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | 5 | const path: ApiHandler["path"] = "/getUserFavorites"; 6 | 7 | const handler: ApiHandler["handler"] = async (req, res) => { 8 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 9 | const dz = sessionDZ[req.session.id]; 10 | 11 | let result: any = {}; 12 | 13 | if (dz.loggedIn) { 14 | const userID = dz.currentUser.id; 15 | 16 | result.playlists = await dz.gw.get_user_playlists(userID, { limit: -1 }); 17 | result.albums = await dz.gw.get_user_albums(userID, { limit: -1 }); 18 | result.artists = await dz.gw.get_user_artists(userID, { limit: -1 }); 19 | // TODO: Lazy load favourites when navigating to relevant tab 20 | result.tracks = await dz.gw.get_my_favorite_tracks({ limit: 100 }); 21 | result.lovedTracks = `https://deezer.com/playlist/${dz.currentUser.loved_tracks}`; 22 | } else { 23 | result = { error: "notLoggedIn" }; 24 | } 25 | res.send(result); 26 | }; 27 | 28 | const apiHandler: ApiHandler = { path, handler }; 29 | 30 | export default apiHandler; 31 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getUserPlaylists.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | 5 | const path: ApiHandler["path"] = "/getUserPlaylists"; 6 | 7 | const handler: ApiHandler["handler"] = async (req, res) => { 8 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 9 | const dz = sessionDZ[req.session.id]; 10 | let data; 11 | 12 | if (dz.loggedIn) { 13 | const userID = dz.currentUser.id; 14 | data = await dz.gw.get_user_playlists(userID, { limit: -1 }); 15 | } else { 16 | data = { error: "notLoggedIn" }; 17 | } 18 | res.send(data); 19 | }; 20 | 21 | const apiHandler: ApiHandler = { path, handler }; 22 | 23 | export default apiHandler; 24 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getUserSpotifyPlaylists.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | const path: ApiHandler["path"] = "/getUserSpotifyPlaylists"; 4 | 5 | const handler: ApiHandler["handler"] = async (req, res) => { 6 | let data; 7 | const deemix = req.app.get("deemix"); 8 | 9 | if (deemix.plugins.spotify.enabled) { 10 | const sp = deemix.plugins.spotify.sp; 11 | const usernames = req.query.spotifyUser.split(/[\s,]+/); 12 | data = []; 13 | let playlistList: any; 14 | playlistList = []; 15 | for (let username of usernames) { 16 | username = username.trim(); 17 | let playlists; 18 | try { 19 | playlists = await sp.playlists.getUsersPlaylists(username); 20 | } catch { 21 | res.send({ error: "wrongSpotifyUsername", username }); 22 | return; 23 | } 24 | playlistList = playlistList.concat(playlists.items); 25 | while (playlists.next) { 26 | const regExec = /offset=(\d+)/g.exec(playlists.next); 27 | const offset = regExec![1]; 28 | // const limit = regExec![2] 29 | playlists = await sp.playlists.getUsersPlaylists( 30 | username, 31 | undefined, 32 | offset 33 | ); 34 | playlistList = playlistList.concat(playlists.items); 35 | } 36 | } 37 | playlistList.forEach((playlist: any) => { 38 | data.push(deemix.plugins.spotify._convertPlaylistStructure(playlist)); 39 | }); 40 | } else { 41 | data = { error: "spotifyNotEnabled" }; 42 | } 43 | res.send(data); 44 | }; 45 | 46 | const apiHandler: ApiHandler = { path, handler }; 47 | 48 | export default apiHandler; 49 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/getUserTracks.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | 5 | const path: ApiHandler["path"] = "/getUserTracks"; 6 | 7 | const handler: ApiHandler["handler"] = async (req, res) => { 8 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 9 | const dz = sessionDZ[req.session.id]; 10 | let data; 11 | 12 | if (dz.loggedIn) { 13 | data = await dz.gw.get_my_favorite_tracks({ limit: -1 }); 14 | } else { 15 | data = { error: "notLoggedIn" }; 16 | } 17 | res.send(data); 18 | }; 19 | 20 | const apiHandler: ApiHandler = { path, handler }; 21 | 22 | export default apiHandler; 23 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/index.ts: -------------------------------------------------------------------------------- 1 | import connect from "./connect.js"; 2 | import analyzeLink from "./analyzeLink.js"; 3 | import getHome from "./getHome.js"; 4 | import getCharts from "./getCharts.js"; 5 | import mainSearch from "./mainSearch.js"; 6 | import search from "./search.js"; 7 | import newReleases from "./newReleases.js"; 8 | import getTracklist from "./getTracklist.js"; 9 | import { apiHandler as albumSearch } from "./albumSearch.js"; 10 | import getChartTracks from "./getChartTracks.js"; 11 | import getSettings from "./getSettings.js"; 12 | import getUserTracks from "./getUserTracks.js"; 13 | import getUserAlbums from "./getUserAlbums.js"; 14 | import getUserArtists from "./getUserArtists.js"; 15 | import getUserPlaylists from "./getUserPlaylists.js"; 16 | import getUserSpotifyPlaylists from "./getUserSpotifyPlaylists.js"; 17 | import getUserFavorites from "./getUserFavorites.js"; 18 | import getQueue from "./getQueue.js"; 19 | import spotifyStatus from "./spotifyStatus.js"; 20 | import checkForUpdates from "./checkForUpdates.js"; 21 | 22 | export default [ 23 | connect, 24 | albumSearch, 25 | analyzeLink, 26 | getHome, 27 | getCharts, 28 | getChartTracks, 29 | mainSearch, 30 | search, 31 | newReleases, 32 | getTracklist, 33 | getSettings, 34 | getUserTracks, 35 | getUserAlbums, 36 | getUserArtists, 37 | getUserPlaylists, 38 | getUserSpotifyPlaylists, 39 | getUserFavorites, 40 | getQueue, 41 | spotifyStatus, 42 | checkForUpdates, 43 | ]; 44 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/newReleases.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | import { getAlbumDetails } from "./albumSearch.js"; 5 | 6 | const path: ApiHandler["path"] = "/newReleases"; 7 | 8 | const handler: ApiHandler["handler"] = async (req, res) => { 9 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 10 | const dz = sessionDZ[req.session.id]; 11 | 12 | const results = await dz.gw.get_page("channels/explore"); 13 | 14 | const music_section = results.sections.find((e: any) => 15 | e.section_id.includes("module_id=83718b7b-5503-4062-b8b9-3530e2e2cefa") 16 | ); 17 | 18 | const channels = music_section.items.map((e: any) => e.target); 19 | 20 | const newReleasesByChannel = ( 21 | await Promise.all(channels.map((c: string) => channelNewReleases(dz, c))) 22 | ); 23 | 24 | const seen = new Set(); 25 | const distinct: any[] = []; 26 | 27 | newReleasesByChannel.forEach((l) => { 28 | l.forEach((r) => { 29 | if (!seen.has(r.ALB_ID)) { 30 | seen.add(r.ALB_ID); 31 | distinct.push(r); 32 | } 33 | }); 34 | }); 35 | 36 | distinct.sort((a, b) => 37 | a.DIGITAL_RELEASE_DATE < b.DIGITAL_RELEASE_DATE 38 | ? 1 39 | : b.DIGITAL_RELEASE_DATE < a.DIGITAL_RELEASE_DATE 40 | ? -1 41 | : 0 42 | ); 43 | 44 | const now = Date.now(); 45 | const delta = 8 * 24 * 60 * 60 * 1000; 46 | 47 | const recent = distinct.filter( 48 | (x: any) => now - Date.parse(x.DIGITAL_RELEASE_DATE) < delta 49 | ); 50 | 51 | const albums = await Promise.all( 52 | recent.map((c: any) => getAlbumDetails(dz, c.ALB_ID)) 53 | ); 54 | 55 | const output = { 56 | data: albums, 57 | total: albums.length, 58 | }; 59 | 60 | res.send(output); 61 | }; 62 | 63 | const apiHandler: ApiHandler = { path, handler }; 64 | 65 | export default apiHandler; 66 | 67 | async function channelNewReleases( 68 | dz: Deezer, 69 | channelName: string 70 | ): Promise { 71 | const channelData = await dz.gw.get_page(channelName); 72 | const re = /^New.*releases$/; 73 | 74 | const newReleases = channelData.sections.find((e: any) => re.test(e.title)); 75 | 76 | if (!newReleases) { 77 | return []; 78 | } else if ("target" in newReleases) { 79 | const showAll = await dz.gw.get_page(newReleases.target); 80 | return showAll.sections[0].items.map((e: any) => e.data); 81 | } else if ("items" in newReleases) { 82 | return newReleases.items.map((e: any) => e.data); 83 | } else { 84 | return []; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/search.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { type ApiHandler } from "../../../types.js"; 4 | 5 | const path: ApiHandler["path"] = "/search"; 6 | 7 | const emptyResult = { 8 | data: [], 9 | total: 0, 10 | type: "", 11 | error: "", 12 | }; 13 | 14 | const handler: ApiHandler["handler"] = async (req, res) => { 15 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 16 | const dz = sessionDZ[req.session.id]; 17 | 18 | const term = String(req.query.term); 19 | const type = String(req.query.type); 20 | const start = parseInt(String(req.query.start)); 21 | const nb = parseInt(String(req.query.nb)); 22 | 23 | let data; 24 | 25 | try { 26 | switch (type) { 27 | case "track": 28 | data = await dz.api.search_track(term, { limit: nb, index: start }); 29 | break; 30 | case "album": 31 | data = await dz.api.search_album(term, { limit: nb, index: start }); 32 | break; 33 | case "artist": 34 | data = await dz.api.search_artist(term, { limit: nb, index: start }); 35 | break; 36 | case "playlist": 37 | data = await dz.api.search_playlist(term, { limit: nb, index: start }); 38 | break; 39 | case "radio": 40 | data = await dz.api.search_radio(term, { limit: nb, index: start }); 41 | break; 42 | case "user": 43 | data = await dz.api.search_user(term, { limit: nb, index: start }); 44 | break; 45 | default: 46 | data = await dz.api.search(term, { limit: nb, index: start }); 47 | break; 48 | } 49 | } catch (e: any) { 50 | data = { ...emptyResult }; 51 | data.error = e.message; 52 | } 53 | 54 | data.type = type; 55 | res.send(data); 56 | }; 57 | 58 | const apiHandler: ApiHandler = { path, handler }; 59 | 60 | export default apiHandler; 61 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/get/spotifyStatus.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | const path: ApiHandler["path"] = "/spotifyStatus"; 4 | 5 | const handler: ApiHandler["handler"] = (req, res) => { 6 | const deemix = req.app.get("deemix"); 7 | res.send({ spotifyEnabled: deemix.plugins.spotify.enabled }); 8 | }; 9 | 10 | const apiHandler: ApiHandler = { path, handler }; 11 | 12 | export default apiHandler; 13 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/patch/index.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | export default [] as ApiHandler[]; 4 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/addToQueue.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { type ApiHandler } from "../../../types.js"; 3 | import { sessionDZ } from "../../../deemixApp.js"; 4 | import { logger } from "../../../helpers/logger.js"; 5 | 6 | const path: ApiHandler["path"] = "/addToQueue"; 7 | 8 | const handler: ApiHandler["handler"] = async (req, res) => { 9 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 10 | const deemix = req.app.get("deemix"); 11 | const dz = sessionDZ[req.session.id]; 12 | 13 | const url = req.body.url.split(/[\s;]+/); 14 | let bitrate = req.body.bitrate; 15 | if (bitrate === "null" || !bitrate) 16 | bitrate = deemix.getSettings().settings.maxBitrate; 17 | bitrate = Number(bitrate); 18 | let obj: any; 19 | 20 | try { 21 | obj = await deemix.addToQueue(dz, url, bitrate); 22 | } catch (e: any) { 23 | res.send({ result: false, errid: e.name, data: { url, bitrate } }); 24 | switch (e.name) { 25 | case "NotLoggedIn": 26 | deemix.listener.send("queueError" + e.name); 27 | break; 28 | case "CantStream": 29 | deemix.listener.send("queueError" + e.name, e.bitrate); 30 | break; 31 | default: 32 | logger.error(e); 33 | break; 34 | } 35 | return; 36 | } 37 | 38 | res.send({ result: true, data: { url, bitrate, obj } }); 39 | }; 40 | 41 | const apiHandler: ApiHandler = { path, handler }; 42 | 43 | export default apiHandler; 44 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/cancelAllDownloads.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | const path = "/cancelAllDownloads"; 4 | 5 | const handler: ApiHandler["handler"] = (req, res) => { 6 | const deemix = req.app.get("deemix"); 7 | deemix.cancelAllDownloads(); 8 | res.send({ result: true }); 9 | }; 10 | 11 | const apiHandler = { path, handler }; 12 | 13 | export default apiHandler; 14 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/changeAccount.test.ts: -------------------------------------------------------------------------------- 1 | import { appSendGet } from "@/tests/utils.js"; 2 | 3 | describe("analyzeLink requests", () => { 4 | test("should respond 200 to calls with supported child number", async () => { 5 | appSendGet("/api/changeAccount/?child=1").expect(200); 6 | }); 7 | 8 | test("should respond 400 to calls with not supported child number", async () => { 9 | appSendGet("/api/changeAccount/").expect(400); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/changeAccount.ts: -------------------------------------------------------------------------------- 1 | import { sessionDZ } from "@/deemixApp.js"; 2 | import { type ApiHandler } from "@/types.js"; 3 | import { Deezer } from "deezer-sdk"; 4 | import type { RequestHandler } from "express"; 5 | 6 | const path: ApiHandler["path"] = "/changeAccount"; 7 | 8 | interface ChangeAccountQuery { 9 | child: number; 10 | } 11 | 12 | const handler: RequestHandler = ( 13 | req, 14 | res 15 | ) => { 16 | if (!req.query || !req.query.child) { 17 | res 18 | .status(400) 19 | .send({ errorMessage: "No child specified", errorCode: "CA01" }); 20 | } 21 | 22 | const { child: accountNum } = req.query; 23 | 24 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 25 | const dz = sessionDZ[req.session.id]; 26 | 27 | const accountData = dz.changeAccount(accountNum); 28 | 29 | res.status(200).send(accountData); 30 | }; 31 | 32 | const apiHandler: ApiHandler = { path, handler }; 33 | 34 | export default apiHandler; 35 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/index.ts: -------------------------------------------------------------------------------- 1 | import changeAccount from "./changeAccount.js"; 2 | import loginArl from "./loginArl.js"; 3 | import addToQueue from "./addToQueue.js"; 4 | import loginEmail from "./loginEmail.js"; 5 | import cancelAllDownloads from "./cancelAllDownloads.js"; 6 | import removeFinishedDownloads from "./removeFinishedDownloads.js"; 7 | import removeFromQueue from "./removeFromQueue.js"; 8 | import logout from "./logout.js"; 9 | import saveSettings from "./saveSettings.js"; 10 | import retryDownload from "./retryDownload.js"; 11 | 12 | export default [ 13 | changeAccount, 14 | loginArl, 15 | addToQueue, 16 | loginEmail, 17 | cancelAllDownloads, 18 | removeFinishedDownloads, 19 | removeFromQueue, 20 | logout, 21 | saveSettings, 22 | retryDownload, 23 | ]; 24 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/loginArl.test.ts: -------------------------------------------------------------------------------- 1 | import { appSendPost } from "@/tests/utils.js"; 2 | 3 | describe("loginArl requests", () => { 4 | test("should respond 200 to calls with arl", async () => { 5 | const batchCalls = ["/api/loginArl/?arl=abcdef1234"]; 6 | 7 | for (const uri of batchCalls) { 8 | appSendPost(uri).expect(200); 9 | } 10 | }); 11 | 12 | test("should respond 400 to calls without arl", async () => { 13 | const batchCalls = [ 14 | "/api/loginArl/", 15 | "/api/loginArl/?dummy=test", 16 | "/api/loginArl/?email=aaa@aa.com", 17 | ]; 18 | 19 | for (const uri of batchCalls) { 20 | appSendPost(uri).expect(400); 21 | } 22 | }); 23 | 24 | test("should login using ARL", async () => { 25 | appSendPost(`/api/loginArl/?arl=${process.env.DEEZER_ARL}`) 26 | .expect(200) 27 | .expect((response) => expect(response.body.status).toBe(true)); 28 | }); 29 | 30 | test("should not login using wrong ARL", async () => { 31 | appSendPost(`/api/loginArl/?arl=abcdef1234`) 32 | .expect(200) 33 | .expect((response) => expect(response.body.status).toBe(false)); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/loginEmail.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "@/types.js"; 2 | import { getAccessToken, getArlFromAccessToken } from "@/deemixApp.js"; 3 | import { saveLoginCredentials } from "@/helpers/loginStorage.js"; 4 | 5 | const path = "/loginEmail"; 6 | 7 | const handler: ApiHandler["handler"] = async (req, res) => { 8 | const isSingleUser = req.app.get("isSingleUser"); 9 | const { email, password } = req.body; 10 | let accessToken = req.body.accessToken; 11 | 12 | if (!accessToken) { 13 | accessToken = await getAccessToken(email, password); 14 | if (accessToken === "undefined") accessToken = undefined; 15 | } 16 | let arl; 17 | if (accessToken) arl = await getArlFromAccessToken(accessToken); 18 | 19 | if (isSingleUser && accessToken) 20 | saveLoginCredentials({ 21 | accessToken, 22 | arl: arl || null, 23 | }); 24 | 25 | res.send({ accessToken, arl }); 26 | }; 27 | 28 | const apiHandler = { path, handler }; 29 | 30 | export default apiHandler; 31 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/logout.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { resetLoginCredentials } from "../../../helpers/loginStorage.js"; 4 | import type { ApiHandler } from "../../../types.js"; 5 | 6 | const path: ApiHandler["path"] = "/logout"; 7 | 8 | const handler: ApiHandler["handler"] = (req, res) => { 9 | sessionDZ[req.session.id] = new Deezer(); 10 | res.send({ logged_out: true }); 11 | if (req.app.get("isSingleUser")) resetLoginCredentials(); 12 | }; 13 | 14 | const apiHandler: ApiHandler = { path, handler }; 15 | 16 | export default apiHandler; 17 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/removeFinishedDownloads.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | const path = "/removeFinishedDownloads"; 4 | 5 | const handler: ApiHandler["handler"] = (req, res) => { 6 | const deemix = req.app.get("deemix"); 7 | deemix.clearCompletedDownloads(); 8 | res.send({ result: true }); 9 | }; 10 | 11 | const apiHandler = { path, handler }; 12 | 13 | export default apiHandler; 14 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/removeFromQueue.ts: -------------------------------------------------------------------------------- 1 | import { type ApiHandler } from "../../../types.js"; 2 | 3 | const path = "/removeFromQueue"; 4 | 5 | const handler: ApiHandler["handler"] = (req, res) => { 6 | const deemix = req.app.get("deemix"); 7 | const { uuid } = req.query; 8 | if (uuid) { 9 | deemix.cancelDownload(uuid); 10 | res.send({ result: true }); 11 | } else { 12 | res.send({ result: false }); 13 | } 14 | }; 15 | 16 | const apiHandler = { path, handler }; 17 | 18 | export default apiHandler; 19 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/retryDownload.ts: -------------------------------------------------------------------------------- 1 | import { Deezer } from "deezer-sdk"; 2 | import { sessionDZ } from "../../../deemixApp.js"; 3 | import { logger } from "../../../helpers/logger.js"; 4 | import { type ApiHandler } from "../../../types.js"; 5 | 6 | const path: ApiHandler["path"] = "/retryDownload"; 7 | 8 | const handler: ApiHandler["handler"] = async (req, res) => { 9 | if (!sessionDZ[req.session.id]) sessionDZ[req.session.id] = new Deezer(); 10 | const deemix = req.app.get("deemix"); 11 | const dz = sessionDZ[req.session.id]; 12 | 13 | const uuid = req.body.uuid; 14 | const data = uuid.split("_"); 15 | let url = ""; 16 | let bitrate = 0; 17 | if (data.length === 4) { 18 | if (data[0] === "spotify") { 19 | url = `https://open.spotify.com/${data[1]}/${data[2]}`; 20 | bitrate = Number(data[3]); 21 | } 22 | } else { 23 | if (data[0] === "playlist" && data[1].endsWith("_top_track")) { 24 | data[0] = "artist"; 25 | data[1] = data[1].replace("_top_track", "/top_track"); 26 | } 27 | url = `https://www.deezer.com/${data[0]}/${data[1]}`; 28 | bitrate = Number(data[2]); 29 | } 30 | let obj: any; 31 | 32 | try { 33 | obj = await deemix.addToQueue(dz, [url], bitrate, true); 34 | } catch (e: any) { 35 | res.send({ result: false, errid: e.name, data: { url, bitrate } }); 36 | switch (e.name) { 37 | case "NotLoggedIn": 38 | deemix.listener.send("queueError" + e.name); 39 | break; 40 | case "CantStream": 41 | deemix.listener.send("queueError" + e.name, e.bitrate); 42 | break; 43 | default: 44 | logger.error(e); 45 | break; 46 | } 47 | return; 48 | } 49 | 50 | res.send({ result: true, data: { url, bitrate, obj } }); 51 | }; 52 | 53 | const apiHandler: ApiHandler = { path, handler }; 54 | 55 | export default apiHandler; 56 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/post/saveSettings.ts: -------------------------------------------------------------------------------- 1 | import type { ApiHandler } from "@/types.js"; 2 | import type { Settings, SpotifySettings } from "deemix"; 3 | 4 | const path = "/saveSettings"; 5 | 6 | export interface SaveSettingsData { 7 | settings: Settings; 8 | spotifySettings: SpotifySettings; 9 | } 10 | 11 | const handler: ApiHandler["handler"] = (req, res) => { 12 | const deemix = req.app.get("deemix"); 13 | const { settings, spotifySettings }: SaveSettingsData = req.query; 14 | deemix.saveSettings(settings, spotifySettings); 15 | deemix.listener.send("updateSettings", { settings, spotifySettings }); 16 | res.send({ result: true }); 17 | }; 18 | 19 | const apiHandler = { path, handler }; 20 | 21 | export default apiHandler; 22 | -------------------------------------------------------------------------------- /webui/src/server/routes/api/register.ts: -------------------------------------------------------------------------------- 1 | import type { Application } from "express"; 2 | import type { ApiHandler } from "../../types.js"; 3 | import getEndpoints from "./get/index.js"; 4 | import deleteEndpoints from "./delete/index.js"; 5 | import postEndpoints from "./post/index.js"; 6 | import patchEndpoints from "./patch/index.js"; 7 | 8 | const prependApiPath = (path: string) => `*/api${path}`; 9 | 10 | interface Method { 11 | method: string; 12 | endpoints: ApiHandler[]; 13 | } 14 | 15 | const methods: Method[] = [ 16 | { 17 | method: "get", 18 | endpoints: getEndpoints, 19 | }, 20 | { 21 | method: "delete", 22 | endpoints: deleteEndpoints, 23 | }, 24 | { 25 | method: "post", 26 | endpoints: postEndpoints, 27 | }, 28 | { 29 | method: "patch", 30 | endpoints: patchEndpoints, 31 | }, 32 | ]; 33 | 34 | export function registerApis(app: Application) { 35 | methods.forEach(({ method, endpoints }) => { 36 | endpoints.forEach((endpoint) => { 37 | app[method](prependApiPath(endpoint.path), endpoint.handler); 38 | }); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /webui/src/server/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from "express"; 2 | 3 | const router: Router = express.Router(); 4 | export default router; 5 | -------------------------------------------------------------------------------- /webui/src/server/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "@/main.js"; 3 | 4 | export const appSendGet = (uri: string) => request(app).get(uri); 5 | export const appSendPost = (uri: string) => request(app).post(uri); 6 | -------------------------------------------------------------------------------- /webui/src/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.config.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "../../dist", 6 | 7 | "paths": { 8 | "@/*": ["./*"] 9 | }, 10 | 11 | "types": ["vitest/globals"] 12 | }, 13 | "include": ["./**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /webui/src/server/websocket/index.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketServer } from "ws"; 2 | 3 | import { logger } from "../helpers/logger.js"; 4 | import { DeemixApp } from "../deemixApp.js"; 5 | import wsModules from "./modules/index.js"; 6 | 7 | export const registerWebsocket = (wss: WebSocketServer, deemix: DeemixApp) => { 8 | wss.on("connection", (ws) => { 9 | ws.on("message", (message) => { 10 | const data = JSON.parse(message.toString()); 11 | 12 | wsModules.forEach((module) => { 13 | if (data.key === module.eventName) { 14 | module.cb(data.data, ws, wss, deemix); 15 | } 16 | }); 17 | }); 18 | }); 19 | 20 | wss.on("error", () => { 21 | logger.error("An error occurred to the WebSocket server."); 22 | }); 23 | 24 | wss.on("close", () => { 25 | logger.info("Connection to the WebSocket server closed."); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /webui/src/server/websocket/modules/cancelAllDownloads.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketServer } from "ws"; 2 | import { logger } from "../../helpers/logger.js"; 3 | import { DeemixApp } from "../../deemixApp.js"; 4 | 5 | const eventName = "cancelAllDownloads"; 6 | 7 | const cb = (_: any, __: any, ___: WebSocketServer, deemix: DeemixApp) => { 8 | deemix.cancelAllDownloads(); 9 | logger.info(`Queue cleared`); 10 | }; 11 | 12 | export default { eventName, cb }; 13 | -------------------------------------------------------------------------------- /webui/src/server/websocket/modules/index.ts: -------------------------------------------------------------------------------- 1 | import saveSettings from "./saveSettings.js"; 2 | import removeFinishedDownloads from "./removeFinishedDownloads.js"; 3 | import removeFromQueue from "./removeFromQueue.js"; 4 | import cancelAllDownloads from "./cancelAllDownloads.js"; 5 | 6 | export default [ 7 | saveSettings, 8 | removeFinishedDownloads, 9 | removeFromQueue, 10 | cancelAllDownloads, 11 | ]; 12 | -------------------------------------------------------------------------------- /webui/src/server/websocket/modules/removeFinishedDownloads.ts: -------------------------------------------------------------------------------- 1 | import { DeemixApp } from "@/deemixApp.js"; 2 | import { logger } from "@/helpers/logger.js"; 3 | import { WebSocketServer } from "ws"; 4 | 5 | const eventName = "removeFinishedDownloads"; 6 | 7 | const cb = (_: any, __: any, ___: WebSocketServer, deemix: DeemixApp) => { 8 | deemix.clearCompletedDownloads(); 9 | logger.info("Completed downloads cleared"); 10 | }; 11 | 12 | export default { eventName, cb }; 13 | -------------------------------------------------------------------------------- /webui/src/server/websocket/modules/removeFromQueue.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketServer } from "ws"; 2 | import { logger } from "@/helpers/logger.js"; 3 | import { DeemixApp } from "@/deemixApp.js"; 4 | 5 | const eventName = "removeFromQueue"; 6 | 7 | const cb = (data: any, __: any, ___: WebSocketServer, deemix: DeemixApp) => { 8 | deemix.cancelDownload(data); 9 | logger.info(`Cancelled ${data}`); 10 | }; 11 | 12 | export default { eventName, cb }; 13 | -------------------------------------------------------------------------------- /webui/src/server/websocket/modules/saveSettings.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketServer } from "ws"; 2 | import { logger } from "@/helpers/logger.js"; 3 | import { DeemixApp } from "@/deemixApp.js"; 4 | import type { Settings, SpotifySettings } from "deemix"; 5 | 6 | const eventName = "saveSettings"; 7 | 8 | export interface SaveSettingsData { 9 | settings: Settings; 10 | spotifySettings: SpotifySettings; 11 | } 12 | 13 | const cb = ( 14 | data: SaveSettingsData, 15 | _: any, 16 | __: WebSocketServer, 17 | deemix: DeemixApp 18 | ) => { 19 | const { settings, spotifySettings } = data; 20 | deemix.saveSettings(settings, spotifySettings); 21 | logger.info("Settings saved"); 22 | deemix.listener.send("updateSettings", { settings, spotifySettings }); 23 | }; 24 | 25 | export default { eventName, cb }; 26 | -------------------------------------------------------------------------------- /webui/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | import defaultTheme from "tailwindcss/defaultTheme"; 2 | 3 | export default { 4 | content: [`./index.html`, `./src/**/*.{js,ts,tsx,vue}`], 5 | theme: { 6 | extend: { 7 | colors: { 8 | grayscale: { 9 | 100: "hsl(0, 0%, 10%)", 10 | 200: "hsl(0, 0%, 20%)", 11 | 300: "hsl(0, 0%, 30%)", 12 | 400: "hsl(0, 0%, 40%)", 13 | 500: "hsl(0, 0%, 50%)", 14 | 600: "hsl(0, 0%, 60%)", 15 | 700: "hsl(0, 0%, 70%)", 16 | 800: "hsl(0, 0%, 80%)", 17 | 840: "hsl(0, 0%, 84%)", // Remove maybe 18 | 870: "hsl(0, 0%, 87%)", // Remove maybe 19 | 900: "hsl(0, 0%, 90%)", 20 | 930: "hsl(0, 0%, 93%)", // Remove maybe 21 | }, 22 | primary: "hsl(210, 100%, 52%)", 23 | background: { 24 | main: "var(--main-background)", 25 | secondary: "var(--secondary-background)", 26 | }, 27 | foreground: "var(--foreground)", 28 | panels: { 29 | bg: "var(--panels-background)", 30 | }, 31 | }, 32 | fontFamily: { 33 | sans: ["Open Sans", ...defaultTheme.fontFamily.sans], 34 | }, 35 | }, 36 | }, 37 | variants: { 38 | textColor: ({ after }) => after(["group-hover"]), 39 | margin: ({ before }) => before(["first"]), 40 | borderWidth: ["responsive", "first", "hover", "focus"], 41 | cursor: ["responsive", "hover"], 42 | }, 43 | corePlugins: { 44 | preflight: false, 45 | }, 46 | plugins: [], 47 | }; 48 | -------------------------------------------------------------------------------- /webui/tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Emit */ 4 | // "noEmit": true, 5 | // "declaration": true, 6 | // "declarationMap": true, 7 | // "sourceMap": true, 8 | 9 | /* Projects */ 10 | "incremental": true, 11 | "composite": true, 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext", 15 | "lib": ["ESNext"], 16 | "useDefineForClassFields": true, 17 | "moduleDetection": "force", 18 | 19 | "types": [], 20 | // "typeRoots": ["./node_modules/@types"], 21 | // "preserveSymlinks": true, 22 | 23 | /* Modules */ 24 | "module": "NodeNext", 25 | "moduleResolution": "NodeNext", 26 | "resolvePackageJsonExports": true, 27 | "resolvePackageJsonImports": true, 28 | "resolveJsonModule": true, 29 | 30 | /* Interop Constraints */ 31 | "esModuleInterop": true, 32 | // "isolatedModules": true, 33 | "verbatimModuleSyntax": true, 34 | // "isolatedDeclarations": true, 35 | "forceConsistentCasingInFileNames": true, 36 | 37 | /* Type Checking */ 38 | "strict": false, 39 | "noImplicitAny": false, 40 | // "strictNullChecks": true, 41 | // "strictFunctionTypes": true, 42 | // "strictBindCallApply": true, 43 | // "strictPropertyInitialization": true, 44 | "noImplicitThis": true, 45 | // "useUnknownInCatchVariables": true, 46 | // "alwaysStrict": true, 47 | // "noUnusedLocals": true, 48 | // "noUnusedParameters": true, 49 | // "exactOptionalPropertyTypes": true, 50 | // "noImplicitReturns": true, 51 | // "noFallthroughCasesInSwitch": true, 52 | // "noUncheckedIndexedAccess": true, 53 | // "noImplicitOverride": true, 54 | // "noPropertyAccessFromIndexSignature": true, 55 | // "allowUnusedLabels": false, 56 | // "allowUnreachableCode": false, 57 | 58 | /* Completeness */ 59 | "skipDefaultLibCheck": true, 60 | "skipLibCheck": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /webui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./src/client/tsconfig.json" }, 5 | { "path": "./src/server/tsconfig.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /webui/vite.config.mts: -------------------------------------------------------------------------------- 1 | import vue from "@vitejs/plugin-vue"; 2 | import { fileURLToPath, URL } from "node:url"; 3 | import { defineConfig } from "vite"; 4 | import svgLoader from "vite-svg-loader"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | build: { 9 | outDir: "dist/public", 10 | }, 11 | resolve: { 12 | alias: { 13 | "@": fileURLToPath(new URL("src/client", import.meta.url)), 14 | }, 15 | }, 16 | plugins: [vue(), svgLoader()], 17 | server: { hmr: { port: 3001 } }, 18 | }); 19 | -------------------------------------------------------------------------------- /webui/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | include: ["src/**/*.test.ts"], 7 | globals: true, 8 | }, 9 | plugins: [tsconfigPaths()], 10 | }); 11 | --------------------------------------------------------------------------------