├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── bugsnag.png └── screenshot.png ├── exportify.html ├── package.json ├── public ├── favicon.png ├── index.html └── robots.txt ├── src ├── App.scss ├── App.test.tsx ├── App.tsx ├── components │ ├── ConfigDropdown.scss │ ├── ConfigDropdown.tsx │ ├── Login.tsx │ ├── Paginator.tsx │ ├── PlaylistExporter.tsx │ ├── PlaylistRow.tsx │ ├── PlaylistSearch.scss │ ├── PlaylistSearch.tsx │ ├── PlaylistTable.test.tsx │ ├── PlaylistTable.tsx │ ├── PlaylistsExporter.tsx │ ├── TopMenu.tsx │ ├── __snapshots__ │ │ └── PlaylistTable.test.tsx.snap │ └── data │ │ ├── PlaylistsData.ts │ │ ├── TracksAlbumData.ts │ │ ├── TracksArtistsData.ts │ │ ├── TracksAudioFeaturesData.ts │ │ ├── TracksBaseData.ts │ │ └── TracksData.ts ├── helpers.ts ├── i18n │ ├── config.ts │ └── locales │ │ ├── ar │ │ └── translation.json │ │ ├── de │ │ └── translation.json │ │ ├── en │ │ └── translation.json │ │ ├── es │ │ └── translation.json │ │ ├── fr │ │ └── translation.json │ │ ├── it │ │ └── translation.json │ │ ├── ja │ │ └── translation.json │ │ ├── nl │ │ └── translation.json │ │ ├── pt │ │ └── translation.json │ │ ├── sv │ │ └── translation.json │ │ └── tr │ │ └── translation.json ├── icons.ts ├── index.scss ├── index.tsx ├── mocks │ └── handlers.ts ├── react-app-env.d.ts ├── reportWebVitals.ts └── setupTests.ts ├── tsconfig.json └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [18.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: 🛠️ Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'yarn' 23 | - name: 📦 Install dependencies 24 | run: yarn install 25 | - name: 🧪 Test 26 | run: yarn test 27 | - name: 🔨 Build 28 | run: yarn build 29 | - name: 🚀 Deploy to GitHub Pages 30 | if: github.ref == 'refs/heads/master' 31 | uses: JamesIves/github-pages-deploy-action@v4 32 | with: 33 | folder: build 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | *.csv 26 | .ipynb_checkpoints 27 | .~* 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.18-alpine 2 | 3 | COPY . / 4 | 5 | RUN yarn install 6 | 7 | EXPOSE 3000 8 | 9 | ENTRYPOINT ["yarn", "start"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Howard Wilson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://api.travis-ci.com/watsonbox/exportify.svg?branch=master)](https://travis-ci.com/github/watsonbox/exportify) 2 | 3 | 4 | 5 | Export your Spotify playlists to [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) by clicking on this link: [https://exportify.app/](https://exportify.app/). 6 | 7 | As many users have noted, there is no way to export/archive/backup playlists from the Spotify client for safekeeping. This application provides a simple interface for doing that using the [Spotify Web API](https://developer.spotify.com/documentation/web-api/). 8 | 9 | **No data will be saved - the entire application runs in the browser.** 10 | 11 | ## Features 12 | 13 | - ⚙️ Optional inclusion of album, artist and audio features data in export files 14 | - 🔍 Playlist search with [advanced search syntax](#advanced-search-syntax) and results export 15 | - 🌓 Dark mode 16 | - 🗺 Available in 10 languages (English, French, Spanish, Italian, German, Portuguese, Swedish, Dutch, Japanese and Arabic) 17 | - 📱 Mobile friendly 18 | - ℹ Quick reference help 19 | - 🚀 [Advanced rate limiting handling](https://github.com/watsonbox/exportify/pull/75) for speedy exports 20 | - 👩‍💻 Modern [React-based development stack](#stack) + test suite 21 | 22 | ## Usage 23 | 24 | 1. Fire up [the app](https://exportify.app/) 25 | 2. Click 'Get Started' 26 | 3. Grant Exportify read-only access to your playlists 27 | 4. Click the 'Export' button to export a playlist 28 | 29 | Click 'Export All' to save a zip file containing a CSV file for each playlist in your account. This may take a while when many playlists exist and/or they are large. 30 | 31 | ### Re-importing Playlists 32 | 33 | Once playlists are saved, it's also pretty straightforward to re-import them into Spotify. Open up the CSV file in Excel, for example, select and copy the `spotify:track:xxx` URIs, then simply create a playlist in Spotify and paste them in. This has only been tested with the desktop app. 34 | 35 | ### Export Format 36 | 37 | Track data is exported in [UTF-8](https://en.wikipedia.org/wiki/UTF-8) encoded [CSV](http://en.wikipedia.org/wiki/Comma-separated_values) format with the following fields from the [Spotify track object](https://developer.spotify.com/documentation/web-api/reference/get-several-tracks): 38 | 39 | - Track URI 40 | - Track Name 41 | - Artist URI(s) 42 | - Artist Name(s) 43 | - Album URI 44 | - Album Name 45 | - Album Artist URI(s) 46 | - Album Artist Name(s) 47 | - Album Release Date 48 | - Album Image URL (typically 640x640px jpeg) 49 | - Disc Number 50 | - Track Number 51 | - Track Duration (ms) 52 | - Track Preview URL (mp3) 53 | - Explicit? 54 | - Popularity 55 | - ISRC ([International Standard Recording Code](https://isrc.ifpi.org/en/)) 56 | - Added By 57 | - Added At 58 | 59 | By clicking on the cog, additional data can be exported. 60 | 61 | 62 | 63 | By selecting "Include artists data", the following fields will be added from the [Spotify artist object](https://developer.spotify.com/documentation/web-api/reference/get-multiple-artists): 64 | 65 | - Artist Genres 66 | 67 | And by selecting "Include audio features data", the following fields will be added from the [Spotify audio features object](https://developer.spotify.com/documentation/web-api/reference/get-several-audio-features): 68 | 69 | - Danceability 70 | - Energy 71 | - Key 72 | - Loudness 73 | - Mode 74 | - Speechiness 75 | - Acousticness 76 | - Instrumentalness 77 | - Liveness 78 | - Valence 79 | - Tempo 80 | - Time Signature 81 | 82 | Additionally, by selecting "Include album data", the following fields will be added from the [Spotify album object (full)](https://developer.spotify.com/documentation/web-api/reference/get-an-album) 83 | 84 | - Album Genres 85 | - Label 86 | - Copyrights 87 | 88 | Note that the more data being exported, the longer the export will take. 89 | 90 | ### Playlist Search 91 | 92 | If you're searching for a specific playlist to export, you can use the search facility to find it quickly by name: 93 | 94 | 95 | 96 | - Searching is _case-insensitive_. 97 | - Search results can be exported as a zip file by clicking "Export Results" 98 | 99 | > [!WARNING] 100 | > Please be aware that if you have a very large number of playlists, there may be a small delay before the first search results appear since the Spotify API itself doesn't allow for searching directly, so all playlists must be retrieved first. 101 | 102 | #### Advanced Search Syntax 103 | 104 | Certain search queries have special meaning: 105 | 106 | | Search query | Meaning | 107 | |----|----| 108 | | `public:true` | Only show public playlists | 109 | | `public:false` | Only show private playlists | 110 | | `collaborative:true` | Only show collaborative playlists | 111 | | `collaborative:false` | Don't show collaborative playlists | 112 | | `owner:me` | Only show playlists I own | 113 | | `owner:[owner]` | Only show playlists owned by `[owner]` | 114 | 115 | 116 | ## Development 117 | 118 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 119 | 120 | In the project directory, first run `yarn install` to set up dependencies, then you can run: 121 | 122 | **`yarn start`** 123 | 124 | Runs the app in the development mode.\ 125 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 126 | 127 | The page will reload if you make edits.\ 128 | You will also see any lint errors in the console. 129 | 130 | **`yarn test`** 131 | 132 | Launches the test runner in the interactive watch mode.\ 133 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 134 | 135 | **`yarn build`** 136 | 137 | Builds the app for production to the `build` folder. 138 | 139 | ### Stack 140 | 141 | In addition to [Create React App](https://github.com/facebook/create-react-app), the application is built using the following tools/libraries: 142 | 143 | * [React](https://reactjs.org/) - A JavaScript library for building user interfaces 144 | * [Bootstrap 5](https://getbootstrap.com/) - styling and UI components 145 | * [Font Awesome 6](https://fontawesome.com/) - vector icon set and toolkit 146 | * [react-i18next](https://react.i18next.com/) - internationalization framework 147 | * [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) - light-weight solution for testing React DOM nodes 148 | * [MSW](https://mswjs.io/) - network-level request mocking (more of my own thoughts [here](https://watsonbox.github.io/posts/2020/11/30/discovering-msw.html)) 149 | 150 | ### History 151 | 152 | - 2015: Exportify is [born](https://github.com/watsonbox/exportify/commit/b284822e12c3adea8fb83258fdb00ec4690701e1) 153 | - 2020: [Major release](https://watsonbox.github.io/posts/2020/12/02/exportify-refresh.html) including search, artist and audio features, liked songs export, and a new rate limiting system 154 | - 2024: [Major release](https://watsonbox.github.io/posts/2024/09/04/exportify-updates.html) including dark mode, internationalization, and search enhancements 155 | 156 | ## Notes 157 | 158 | - According to Spotify's [documentation](https://developer.spotify.com/web-api/working-with-playlists/): 159 | 160 | > Folders are not returned through the Web API at the moment, nor can be created using it". 161 | 162 | Unfortunately that's just how it is. 163 | 164 | - I've [gone to some lengths](https://github.com/watsonbox/exportify/pull/75) to try to eliminate errors resulting from excessively high usage of the Spotify API. Nonetheless, exporting data in bulk is a fairly request-intensive process, so please do try to use this tool responsibly. If you do require more throughput, please consider [creating your own Spotify application](https://github.com/watsonbox/exportify/issues/6#issuecomment-110793132) which you can use with Exportify directly. 165 | 166 | - Disclaimer: It should be clear, but this project is not affiliated with Spotify in any way. It's just an app using their API like any other, with a cheeky name and logo 😇. 167 | 168 | - In case you don't see the playlists you were expecting to see and realize you've accidentally deleted them, it's actually possible to [recover them](https://support.spotify.com/us/article/can-i-recover-a-deleted-playlist/). 169 | 170 | 171 | ## Error Monitoring 172 | 173 | Error monitoring provided by Bugsnag. 174 | 175 | 176 | 177 | 178 | 179 | ## Running With Docker 180 | 181 | To build and run Exportify with docker, run: 182 | 183 | **`docker build . -t exportify`** 184 | 185 | **`docker run -p 3000:3000 exportify`** 186 | 187 | And then open [http://localhost:3000](http://localhost:3000) to view it in the browser. 188 | 189 | ## Contributing 190 | 191 | 1. Fork it ( https://github.com/watsonbox/exportify/fork ) 192 | 2. Create your feature branch (`git checkout -b my-new-feature`) 193 | 3. Commit your changes (`git commit -am 'Add some feature'`) 194 | 4. Push to the branch (`git push origin my-new-feature`) 195 | 5. Create a new Pull Request 196 | -------------------------------------------------------------------------------- /assets/bugsnag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watsonbox/exportify/930ea7ab72133afa28e1fb89694d6a6cf73cd47c/assets/bugsnag.png -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watsonbox/exportify/930ea7ab72133afa28e1fb89694d6a6cf73cd47c/assets/screenshot.png -------------------------------------------------------------------------------- /exportify.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exportify", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://exportify.app", 6 | "dependencies": { 7 | "@bugsnag/js": "^7.25.0", 8 | "@bugsnag/plugin-react": "^7.25.0", 9 | "@fortawesome/fontawesome-svg-core": "^6.6.0", 10 | "@fortawesome/free-brands-svg-icons": "^6.6.0", 11 | "@fortawesome/free-regular-svg-icons": "^6.6.0", 12 | "@fortawesome/free-solid-svg-icons": "^6.6.0", 13 | "@fortawesome/react-fontawesome": "^0.2.2", 14 | "@testing-library/jest-dom": "^6.4.8", 15 | "@testing-library/react": "^16.0.0", 16 | "@testing-library/user-event": "^14.5.2", 17 | "@types/file-saver": "^2.0.7", 18 | "@types/jest": "^29.5.12", 19 | "@types/node": "^22.4.1", 20 | "@types/react": "^18.3.3", 21 | "@types/react-bootstrap": "^0.32.37", 22 | "@types/react-dom": "^18.3.0", 23 | "axios": "^1.8.2", 24 | "bootstrap": "^5.3.3", 25 | "bottleneck": "^2.19.5", 26 | "eslint-plugin-jest-dom": "^5.4.0", 27 | "eslint-plugin-testing-library": "^6.3.0", 28 | "file-saver": "^2.0.5", 29 | "i18next": "^23.14.0", 30 | "i18next-browser-languagedetector": "^8.0.0", 31 | "jszip": "^3.10.1", 32 | "react": "^18.3.1", 33 | "react-bootstrap": "^2.10.4", 34 | "react-dom": "^18.3.1", 35 | "react-i18next": "^15.0.1", 36 | "react-scripts": "^5.0.1", 37 | "stream": "^0.0.3", 38 | "typescript": "^5.5.4", 39 | "url-search-params-polyfill": "^8.2.5", 40 | "web-vitals": "^4.2.3" 41 | }, 42 | "scripts": { 43 | "start": "react-scripts start", 44 | "build": "react-scripts build", 45 | "test": "react-scripts test", 46 | "eject": "react-scripts eject" 47 | }, 48 | "eslintConfig": { 49 | "extends": [ 50 | "react-app", 51 | "react-app/jest", 52 | "plugin:jest-dom/recommended" 53 | ] 54 | }, 55 | "browserslist": { 56 | "production": [ 57 | ">0.2%", 58 | "not dead", 59 | "not op_mini all" 60 | ], 61 | "development": [ 62 | "last 1 chrome version", 63 | "last 1 firefox version", 64 | "last 1 safari version" 65 | ] 66 | }, 67 | "jest": { 68 | "transformIgnorePatterns": [ 69 | "node_modules/(?!axios)/" 70 | ] 71 | }, 72 | "devDependencies": { 73 | "@testing-library/dom": "^10.4.0", 74 | "gh-pages": "^6.1.1", 75 | "msw": "^0.49.1", 76 | "react-test-renderer": "^18.3.1", 77 | "sass": "^1.77.8" 78 | }, 79 | "resolutions": { 80 | "**/fork-ts-checker-webpack-plugin": "^6.5.3" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watsonbox/exportify/930ea7ab72133afa28e1fb89694d6a6cf73cd47c/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Exportify 11 | 12 | 13 | 14 | 15 | 16 |
17 | Fork me on Github 18 |
19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | .App-header { 2 | padding: 40px 15px; 3 | text-align: center; 4 | position: relative; 5 | } 6 | 7 | #spotifyErrorMessage { 8 | text-align: center; 9 | } 10 | 11 | #loginButton { 12 | display: block; 13 | margin: 0 auto; 14 | } 15 | 16 | #topMenu { 17 | position: absolute; 18 | top: 0; 19 | right: 0; 20 | padding: 20px 0; 21 | 22 | button { 23 | color: #dee2e6; 24 | padding: 10px 6px; 25 | 26 | &:hover { 27 | color: silver; 28 | } 29 | } 30 | } 31 | 32 | #languageDropdown { 33 | .dropdown-item svg { 34 | &.selected { 35 | color: #5cb85c; 36 | } 37 | 38 | &:not(.selected) { 39 | opacity: 0.1; 40 | } 41 | } 42 | } 43 | 44 | @keyframes fadeIn { 45 | 0% { 46 | opacity: 0; 47 | } 48 | 49 | 100% { 50 | opacity: 1; 51 | } 52 | } 53 | 54 | #playlists { 55 | animation: fadeIn 1s; 56 | 57 | table { 58 | thead { 59 | th { 60 | border-top-width: 0; 61 | 62 | &.icon { 63 | width: 30px; 64 | } 65 | 66 | &.owner { 67 | width: 150px; 68 | } 69 | 70 | &.tracks { 71 | width: 100px; 72 | } 73 | 74 | &.public, 75 | &.collaborative { 76 | width: 120px; 77 | } 78 | 79 | &.export { 80 | width: 100px; 81 | } 82 | } 83 | } 84 | 85 | &.table-sm { 86 | 87 | td, 88 | th { 89 | padding: 8px; 90 | } 91 | } 92 | } 93 | } 94 | 95 | #playlistsHeader { 96 | display: flex; 97 | flex-direction: row-reverse; 98 | 99 | .paginator { 100 | margin-left: 20px; 101 | } 102 | 103 | .progress { 104 | flex-grow: 1; 105 | height: 30px; 106 | 107 | .progress-bar { 108 | white-space: nowrap; 109 | padding: 4px 10px; 110 | text-align: left; 111 | 112 | // Transitioning when resetting looks weird 113 | &[aria-valuenow="0"] { 114 | transition: none; 115 | } 116 | } 117 | } 118 | 119 | form { 120 | margin-left: 20px; 121 | } 122 | } 123 | 124 | #playlistsFooter { 125 | display: flex; 126 | flex-direction: row-reverse; 127 | gap: 20px; 128 | } 129 | 130 | @keyframes spinner { 131 | to { 132 | transform: rotate(360deg); 133 | } 134 | } 135 | 136 | @-webkit-keyframes spinner { 137 | to { 138 | -webkit-transform: rotate(360deg); 139 | } 140 | } 141 | 142 | .spinner { 143 | min-width: 24px; 144 | min-height: 24px; 145 | } 146 | 147 | .spinner:before { 148 | content: 'Loading…'; 149 | position: absolute; 150 | top: 240px; 151 | left: 50%; 152 | width: 100px; 153 | height: 100px; 154 | margin-top: -50px; 155 | margin-left: -50px; 156 | } 157 | 158 | .spinner:not(:required):before { 159 | content: ''; 160 | border-radius: 50%; 161 | border: 4px solid rgba(236, 235, 232, 1); 162 | border-top-color: rgba(130, 130, 130, 1); 163 | animation: spinner 1s linear infinite; 164 | -webkit-animation: spinner 1s linear infinite; 165 | } 166 | 167 | .ribbon { 168 | background-color: #84BD00; 169 | overflow: hidden; 170 | white-space: nowrap; 171 | /* top left corner */ 172 | position: absolute; 173 | left: -50px; 174 | top: 40px; 175 | /* 45 deg ccw rotation */ 176 | -webkit-transform: rotate(-45deg); 177 | -moz-transform: rotate(-45deg); 178 | -ms-transform: rotate(-45deg); 179 | -o-transform: rotate(-45deg); 180 | transform: rotate(-45deg); 181 | /* shadow */ 182 | -webkit-box-shadow: 0 0 10px #888; 183 | -moz-box-shadow: 0 0 10px #888; 184 | box-shadow: 0 0 10px #888; 185 | } 186 | 187 | .ribbon a { 188 | border: 1px solid #ded; 189 | color: #fff; 190 | display: block; 191 | font: bold 81.25% 'Helvetica Neue', Helvetica, Arial, sans-serif; 192 | margin: 1px 0; 193 | padding: 10px 50px; 194 | text-align: center; 195 | text-decoration: none; 196 | /* shadow */ 197 | text-shadow: 0 0 5px #444; 198 | } 199 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import i18n from "i18n/config" 3 | import { render, screen } from "@testing-library/react" 4 | import userEvent from "@testing-library/user-event" 5 | import App from "./App" 6 | 7 | const { location } = window 8 | 9 | beforeAll(() => { 10 | // @ts-ignore 11 | delete window.location 12 | }) 13 | 14 | afterAll(() => { 15 | window.location = location 16 | }) 17 | 18 | beforeAll(() => { 19 | // @ts-ignore 20 | window.location = { hash: "" } 21 | }) 22 | 23 | beforeEach(() => { 24 | i18n.changeLanguage("en") 25 | }) 26 | 27 | describe("i18n", () => { 28 | test("language can be changed to French", async () => { 29 | render() 30 | 31 | const linkElement = screen.getByText(/Get Started/i) 32 | expect(linkElement).toHaveTextContent("Get Started") 33 | 34 | const changeLanguageButton = screen.getByTitle(/Change language/i).getElementsByTagName("button")[0] 35 | await userEvent.click(changeLanguageButton) 36 | 37 | const frenchLanguageElement = screen.getByText(/Français/i) 38 | expect(frenchLanguageElement).toBeInTheDocument() 39 | 40 | await userEvent.click(frenchLanguageElement) 41 | 42 | expect(screen.getByText(/Commencer/)).toBeInTheDocument() 43 | expect(linkElement).toHaveTextContent("Commencer") 44 | }) 45 | }) 46 | 47 | describe("logging in", () => { 48 | test("renders get started button and redirects to Spotify with correct scopes", async () => { 49 | render() 50 | 51 | const linkElement = screen.getByText(/Get Started/i) 52 | 53 | expect(linkElement).toBeInTheDocument() 54 | 55 | await userEvent.click(linkElement) 56 | 57 | expect(window.location.href).toBe( 58 | "https://accounts.spotify.com/authorize?client_id=9950ac751e34487dbbe027c4fd7f8e99&redirect_uri=%2F%2F&scope=playlist-read-private%20playlist-read-collaborative%20user-library-read&response_type=token&show_dialog=false" 59 | ) 60 | }) 61 | 62 | describe("post-login state", () => { 63 | beforeAll(() => { 64 | // @ts-ignore 65 | window.location = { hash: "#access_token=TEST_ACCESS_TOKEN" } 66 | }) 67 | 68 | test("renders playlist component on return from Spotify with auth token", () => { 69 | render() 70 | 71 | expect(screen.getByTestId('playlistTableSpinner')).toBeInTheDocument() 72 | }) 73 | }) 74 | }) 75 | 76 | describe("logging out", () => { 77 | beforeAll(() => { 78 | // @ts-ignore 79 | window.location = { hash: "#access_token=TEST_ACCESS_TOKEN", href: "https://www.example.com/#access_token=TEST_ACCESS_TOKEN" } 80 | }) 81 | 82 | test("redirects user to login screen which will force a permission request", async () => { 83 | const { rerender } = render() 84 | 85 | const changeUserElement = screen.getByTitle("Change user") 86 | 87 | expect(changeUserElement).toBeInTheDocument() 88 | 89 | await userEvent.click(changeUserElement) 90 | 91 | expect(window.location.href).toBe("https://www.example.com/?change_user=true") 92 | 93 | 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.scss' 2 | import "./icons" 3 | 4 | import React, { useState } from 'react' 5 | import { useTranslation, Translation } from "react-i18next" 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 7 | import "url-search-params-polyfill" 8 | 9 | import Login from 'components/Login' 10 | import PlaylistTable from "components/PlaylistTable" 11 | import { getQueryParam } from "helpers" 12 | import TopMenu from "components/TopMenu" 13 | 14 | function App() { 15 | useTranslation() 16 | const [subtitle, setSubtitle] = useState({(t) => t("tagline")}) 17 | 18 | let view 19 | let key = new URLSearchParams(window.location.hash.substring(1)) 20 | 21 | const onSetSubtitle = (subtitle: any) => { 22 | setSubtitle(subtitle) 23 | } 24 | 25 | if (getQueryParam('spotify_error') !== '') { 26 | view =
27 |

28 |

Oops, Exportify has encountered an unexpected error (5XX) while using the Spotify API. This kind of error is due to a problem on Spotify's side, and although it's rare, unfortunately all we can do is retry later.

29 |

Keep an eye on the Spotify Web API Status page to see if there are any known problems right now, and then retry.

30 |
31 | } else if (key.has('access_token')) { 32 | view = 33 | } else { 34 | view = 35 | } 36 | 37 | return ( 38 |
39 |
40 |
41 | 42 |

43 | Exportify 44 |

45 | 46 |

{subtitle}

47 |
48 | 49 | {view} 50 |
51 | ); 52 | } 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /src/components/ConfigDropdown.scss: -------------------------------------------------------------------------------- 1 | .dropdown.configDropdown { 2 | margin-left: 20px; 3 | 4 | button { 5 | padding: 0; 6 | height: 31px; 7 | color: #dee2e6; 8 | 9 | &:hover { 10 | color: silver; 11 | } 12 | } 13 | 14 | &.show { 15 | button { 16 | color: #5cb85c; 17 | } 18 | } 19 | 20 | .dropdown-toggle::after { 21 | display: none; 22 | } 23 | 24 | .dropdown-menu { 25 | box-shadow: 1px 1px 4px 0px rgba(0, 0, 0, 0.2); 26 | } 27 | 28 | .dropdown-item { 29 | 30 | &:active, 31 | &:hover { 32 | color: inherit; 33 | background: none; 34 | } 35 | 36 | label { 37 | display: block; 38 | cursor: pointer; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ConfigDropdown.tsx: -------------------------------------------------------------------------------- 1 | import './ConfigDropdown.scss' 2 | 3 | import React from "react" 4 | import { withTranslation, WithTranslation } from "react-i18next" 5 | import { Dropdown, Form } from "react-bootstrap" 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 7 | 8 | interface ConfigDropdownProps extends WithTranslation { 9 | onConfigChanged: (config: any) => void 10 | } 11 | 12 | class ConfigDropdown extends React.Component { 13 | private includeArtistsDataCheck = React.createRef() 14 | private includeAudioFeaturesDataCheck = React.createRef() 15 | private includeAlbumDataCheck = React.createRef() 16 | 17 | state = { 18 | spin: false 19 | } 20 | 21 | handleCheckClick = (event: React.MouseEvent) => { 22 | event.stopPropagation() 23 | 24 | if ((event.target as HTMLElement).nodeName === "INPUT") { 25 | this.props.onConfigChanged({ 26 | includeArtistsData: this.includeArtistsDataCheck.current?.checked || false, 27 | includeAudioFeaturesData: this.includeAudioFeaturesDataCheck.current?.checked || false, 28 | includeAlbumData: this.includeAlbumDataCheck.current?.checked || false 29 | }) 30 | } 31 | } 32 | 33 | spin(spin: boolean) { 34 | this.setState({ spin: spin }) 35 | } 36 | 37 | render() { 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | 45 | 51 | 52 | 53 | 58 | 59 | 60 | 65 | 66 | 67 | 68 | ) 69 | } 70 | } 71 | 72 | // https://stackoverflow.com/a/77677875 73 | export interface ConfigDropdownRef extends ConfigDropdown { } 74 | export default withTranslation("translations", { withRef: true })(ConfigDropdown) as 75 | React.ForwardRefExoticComponent & React.RefAttributes> 76 | -------------------------------------------------------------------------------- /src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { withTranslation, WithTranslation } from "react-i18next" 3 | import { Button } from "react-bootstrap" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | import { getQueryParam } from "helpers" 6 | 7 | class Login extends React.Component { 8 | authorize() { 9 | let clientId = getQueryParam("app_client_id") 10 | let changeUser = getQueryParam("change_user") !== "" 11 | 12 | // Use Exportify application clientId if none given 13 | if (clientId === '') { 14 | clientId = "9950ac751e34487dbbe027c4fd7f8e99" 15 | } 16 | 17 | window.location.href = "https://accounts.spotify.com/authorize" + 18 | "?client_id=" + clientId + 19 | "&redirect_uri=" + encodeURIComponent([window.location.protocol, '//', window.location.host, window.location.pathname].join('')) + 20 | "&scope=playlist-read-private%20playlist-read-collaborative%20user-library-read" + 21 | "&response_type=token" + 22 | "&show_dialog=" + changeUser; 23 | } 24 | 25 | render() { 26 | return ( 27 | 30 | ) 31 | } 32 | } 33 | 34 | export default withTranslation()(Login) 35 | -------------------------------------------------------------------------------- /src/components/Paginator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface PaginatorProps { 4 | currentPage: number, 5 | totalRecords: number, 6 | pageLimit: number, 7 | onPageChanged: (page: number) => void 8 | } 9 | 10 | class Paginator extends React.Component { 11 | nextClick = (e: any) => { 12 | e.preventDefault() 13 | 14 | this.props.onPageChanged(this.props.currentPage + 1) 15 | } 16 | 17 | prevClick = (e: any) => { 18 | e.preventDefault() 19 | 20 | this.props.onPageChanged(this.props.currentPage - 1) 21 | } 22 | 23 | totalPages = () => { 24 | return Math.ceil(this.props.totalRecords / this.props.pageLimit) 25 | } 26 | 27 | render() { 28 | return ( 29 | 45 | ) 46 | } 47 | } 48 | 49 | export default Paginator 50 | -------------------------------------------------------------------------------- /src/components/PlaylistExporter.tsx: -------------------------------------------------------------------------------- 1 | import { saveAs } from "file-saver" 2 | import i18n from "../i18n/config" 3 | 4 | import TracksData from "components/data/TracksData" 5 | import TracksBaseData from "components/data/TracksBaseData" 6 | import TracksArtistsData from "components/data/TracksArtistsData" 7 | import TracksAudioFeaturesData from "components/data/TracksAudioFeaturesData" 8 | import TracksAlbumData from "components/data/TracksAlbumData" 9 | 10 | class TracksCsvFile { 11 | playlist: any 12 | trackItems: any 13 | columnNames: string[] 14 | lineData: Map 15 | 16 | lineTrackUris: string[] 17 | lineTrackData: string[][] 18 | 19 | constructor(playlist: any, trackItems: any) { 20 | this.playlist = playlist 21 | this.trackItems = trackItems 22 | this.columnNames = [ 23 | i18n.t("track.added_by"), 24 | i18n.t("track.added_at") 25 | ] 26 | 27 | this.lineData = new Map() 28 | this.lineTrackUris = trackItems.map((i: any) => i.track.uri) 29 | this.lineTrackData = trackItems.map((i: any) => [ 30 | i.added_by == null ? '' : i.added_by.uri, 31 | i.added_at 32 | ]) 33 | } 34 | 35 | async addData(tracksData: TracksData, before = false) { 36 | if (before) { 37 | this.columnNames.unshift(...tracksData.dataLabels()) 38 | } else { 39 | this.columnNames.push(...tracksData.dataLabels()) 40 | } 41 | 42 | const data: Map = await tracksData.data() 43 | 44 | this.lineTrackUris.forEach((uri: string, index: number) => { 45 | if (data.has(uri)) { 46 | if (before) { 47 | this.lineTrackData[index].unshift(...data.get(uri)!) 48 | } else { 49 | this.lineTrackData[index].push(...data.get(uri)!) 50 | } 51 | } 52 | }) 53 | } 54 | 55 | content(): string { 56 | let csvContent = '' 57 | 58 | csvContent += this.columnNames.map(this.sanitize).join() + "\n" 59 | 60 | this.lineTrackData.forEach((lineTrackData, trackId) => { 61 | csvContent += lineTrackData.map(this.sanitize).join(",") + "\n" 62 | }) 63 | 64 | return csvContent 65 | } 66 | 67 | sanitize(string: string): string { 68 | return '"' + String(string).replace(/"/g, '""') + '"' 69 | } 70 | } 71 | 72 | // Handles exporting a single playlist as a CSV file 73 | class PlaylistExporter { 74 | accessToken: string 75 | playlist: any 76 | config: any 77 | 78 | constructor(accessToken: string, playlist: any, config: any) { 79 | this.accessToken = accessToken 80 | this.playlist = playlist 81 | this.config = config 82 | } 83 | 84 | async export() { 85 | return this.csvData().then((data) => { 86 | var blob = new Blob([data], { type: "text/csv;charset=utf-8" }) 87 | saveAs(blob, this.fileName(), { autoBom: false }) 88 | }) 89 | } 90 | 91 | async csvData() { 92 | const tracksBaseData = new TracksBaseData(this.accessToken, this.playlist) 93 | const items = await tracksBaseData.trackItems() 94 | const tracks = items.map(i => i.track) 95 | const tracksCsvFile = new TracksCsvFile(this.playlist, items) 96 | 97 | // Add base data before existing (item) data, for backward compatibility 98 | await tracksCsvFile.addData(tracksBaseData, true) 99 | 100 | if (this.config.includeArtistsData) { 101 | await tracksCsvFile.addData(new TracksArtistsData(this.accessToken, tracks)) 102 | } 103 | 104 | if (this.config.includeAudioFeaturesData) { 105 | await tracksCsvFile.addData(new TracksAudioFeaturesData(this.accessToken, tracks)) 106 | } 107 | 108 | if (this.config.includeAlbumData) { 109 | await tracksCsvFile.addData(new TracksAlbumData(this.accessToken, tracks)) 110 | } 111 | 112 | return tracksCsvFile.content() 113 | } 114 | 115 | fileName(withExtension = true): string { 116 | return this.playlist.name.replace(/[\x00-\x1F\x7F/\\<>:;"|=,.?*[\] ]+/g, "_").toLowerCase() + (withExtension ? this.fileExtension() : "") // eslint-disable-line no-control-regex 117 | } 118 | 119 | fileExtension(): string { 120 | return ".csv" 121 | } 122 | } 123 | 124 | export default PlaylistExporter 125 | -------------------------------------------------------------------------------- /src/components/PlaylistRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { withTranslation, WithTranslation } from "react-i18next" 3 | import { Button } from "react-bootstrap" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | 6 | import { apiCallErrorHandler } from "helpers" 7 | import PlaylistExporter from "./PlaylistExporter" 8 | 9 | interface PlaylistRowProps extends WithTranslation { 10 | accessToken: string, 11 | key: string, 12 | playlist: any, 13 | config: any 14 | } 15 | 16 | class PlaylistRow extends React.Component { 17 | state = { 18 | exporting: false 19 | } 20 | 21 | exportPlaylist = () => { 22 | this.setState( 23 | { exporting: true }, 24 | () => { 25 | (new PlaylistExporter( 26 | this.props.accessToken, 27 | this.props.playlist, 28 | this.props.config 29 | )).export().catch(apiCallErrorHandler).then(() => { 30 | this.setState({ exporting: false }) 31 | }) 32 | } 33 | ) 34 | } 35 | 36 | renderTickCross(condition: boolean) { 37 | if (condition) { 38 | return 39 | } else { 40 | return 41 | } 42 | } 43 | 44 | renderIcon(playlist: any) { 45 | if (playlist.name === 'Liked') { 46 | return ; 47 | } else { 48 | return ; 49 | } 50 | } 51 | 52 | render() { 53 | let playlist = this.props.playlist 54 | const icon = ['fas', (this.state.exporting ? 'sync' : 'download')] 55 | 56 | if (playlist.uri == null) return ( 57 | 58 | {this.renderIcon(playlist)} 59 | {playlist.name} 60 | {this.props.i18n.t("playlist.not_supported")} 61 | {this.renderTickCross(playlist.public)} 62 | {this.renderTickCross(playlist.collaborative)} 63 |   64 | 65 | ); 66 | 67 | return ( 68 | 69 | {this.renderIcon(playlist)} 70 | {playlist.name} 71 | {playlist.owner.display_name} 72 | {playlist.tracks.total} 73 | {this.renderTickCross(playlist.public)} 74 | {this.renderTickCross(playlist.collaborative)} 75 | 76 | {/* @ts-ignore */} 77 | 81 | 82 | 83 | ); 84 | } 85 | } 86 | 87 | export default withTranslation()(PlaylistRow) 88 | -------------------------------------------------------------------------------- /src/components/PlaylistSearch.scss: -------------------------------------------------------------------------------- 1 | #playlistsHeader { 2 | form.search { 3 | input { 4 | width: 64px; 5 | transition: width 250ms ease-in-out; 6 | border-color: #dee2e6; 7 | 8 | &:focus { 9 | width: 200px; 10 | } 11 | } 12 | 13 | &.queryPresent { 14 | input { 15 | width: 200px; 16 | } 17 | } 18 | 19 | .input-group-text { 20 | color: #dee2e6; 21 | padding-left: 1; 22 | } 23 | 24 | .closeIcon, 25 | .searchIcon { 26 | cursor: pointer; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/PlaylistSearch.tsx: -------------------------------------------------------------------------------- 1 | import './PlaylistSearch.scss' 2 | 3 | import React from "react" 4 | import { withTranslation, WithTranslation } from "react-i18next" 5 | import { Form, InputGroup } from "react-bootstrap" 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 7 | 8 | interface PlaylistSearchProps extends WithTranslation { 9 | onPlaylistSearch: (query: string) => void 10 | onPlaylistSearchCancel: () => Promise 11 | } 12 | 13 | class PlaylistSearch extends React.Component { 14 | private searchField = React.createRef() 15 | 16 | state = { 17 | searchSubmitted: false, 18 | query: "" 19 | } 20 | 21 | clear() { 22 | this.setState( 23 | { searchSubmitted: false, query: "" }, 24 | () => { 25 | if (this.searchField.current) { 26 | this.searchField.current.value = "" 27 | } 28 | } 29 | ) 30 | } 31 | 32 | handleKeyDown = (event: React.KeyboardEvent) => { 33 | event.stopPropagation() 34 | 35 | if (event.key === 'Enter') { 36 | this.submitSearch() 37 | 38 | event.preventDefault() 39 | } else if (event.key === 'Escape') { 40 | this.cancelSearch() 41 | } 42 | } 43 | 44 | handleChange = (event: React.ChangeEvent) => { 45 | this.setState({ query: event.target.value }) 46 | } 47 | 48 | private submitSearch = () => { 49 | if (this.state.query.length > 0) { 50 | this.setState( 51 | { searchSubmitted: true }, 52 | () => { this.props.onPlaylistSearch(this.state.query) } 53 | ) 54 | } 55 | } 56 | 57 | private cancelSearch = () => { 58 | this.props.onPlaylistSearchCancel().then(() => { 59 | this.clear() 60 | 61 | if (this.searchField.current) { 62 | this.searchField.current.blur() 63 | } 64 | }) 65 | } 66 | 67 | render() { 68 | const icon = (this.state.searchSubmitted) 69 | ? 70 | : 71 | 72 | const className = this.state.query.length > 0 ? "search queryPresent" : "search" 73 | 74 | return ( 75 |
76 | 77 | 78 | 79 | {icon} 80 | 81 | 82 | 83 |
84 | ) 85 | } 86 | } 87 | 88 | // https://stackoverflow.com/a/77677875 89 | export interface PlaylistSearchRef extends PlaylistSearch { } 90 | export default withTranslation("translations", { withRef: true })(PlaylistSearch) as 91 | React.ForwardRefExoticComponent & React.RefAttributes> 92 | -------------------------------------------------------------------------------- /src/components/PlaylistTable.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import "i18n/config" 3 | import { render, screen, waitFor, act, waitForElementToBeRemoved } from "@testing-library/react" 4 | import userEvent from "@testing-library/user-event" 5 | import { setupServer } from "msw/node" 6 | import FileSaver from "file-saver" 7 | import JSZip from "jszip" 8 | 9 | import PlaylistTable from "./PlaylistTable" 10 | 11 | import "../icons" 12 | import { handlerCalled, handlers, nullAlbumHandlers, nullTrackHandlers, localTrackHandlers, duplicateTrackHandlers, missingPlaylistsHandlers } from "../mocks/handlers" 13 | 14 | const server = setupServer(...handlers) 15 | 16 | // Mock out Bugsnag calls 17 | jest.mock('@bugsnag/js') 18 | const onSetSubtitle = jest.fn() 19 | 20 | server.listen({ 21 | onUnhandledRequest: 'warn' 22 | }) 23 | 24 | beforeAll(() => { 25 | // @ts-ignore 26 | global.Blob = function (content, options) { return ({ content, options }) } 27 | 28 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 29 | Object.defineProperty(window, 'matchMedia', { 30 | writable: true, 31 | value: jest.fn().mockImplementation(query => ({ 32 | matches: false, 33 | media: query, 34 | onchange: null, 35 | addListener: jest.fn(), // Deprecated 36 | removeListener: jest.fn(), // Deprecated 37 | addEventListener: jest.fn(), 38 | removeEventListener: jest.fn(), 39 | dispatchEvent: jest.fn(), 40 | })), 41 | }); 42 | }) 43 | 44 | const { location } = window 45 | 46 | beforeAll(() => { 47 | // @ts-ignore 48 | delete window.location 49 | }) 50 | 51 | afterAll(() => { 52 | window.location = location 53 | }) 54 | 55 | afterEach(() => { 56 | jest.restoreAllMocks() 57 | server.resetHandlers() 58 | }) 59 | 60 | const baseTrackHeaders = '"Track URI","Track Name","Artist URI(s)","Artist Name(s)","Album URI","Album Name","Album Artist URI(s)","Album Artist Name(s)","Album Release Date","Album Image URL","Disc Number","Track Number","Track Duration (ms)","Track Preview URL","Explicit","Popularity","ISRC","Added By","Added At"' 61 | const baseTrackDataCrying = '"spotify:track:1GrLfs4TEvAZ86HVzXHchS","Crying","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","2017-02-17","https://i.scdn.co/image/ab67616d0000b273f485821b346237acbbca07ea","1","3","198093","https://p.scdn.co/mp3-preview/daf08df57a49c215c8c53dc5fe88dec5461f15c9?cid=9950ac751e34487dbbe027c4fd7f8e99","false","2","UK4UP1300002","","2020-07-19T09:24:39Z"' 62 | 63 | // Use a snapshot test to ensure exact component rendering 64 | test("playlist loading", async () => { 65 | const { asFragment } = render() 66 | 67 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 68 | 69 | expect(asFragment()).toMatchSnapshot(); 70 | }) 71 | 72 | test("redirecting when access token is invalid", async () => { 73 | // @ts-ignore 74 | window.location = { href: "http://www.example.com/exportify#access_token=INVALID_ACCESS_TOKEN" } 75 | 76 | render() 77 | 78 | await waitFor(() => { 79 | expect(window.location.href).toBe("http://www.example.com/exportify") 80 | }) 81 | }) 82 | 83 | describe("single playlist exporting", () => { 84 | test("standard case exports successfully", async () => { 85 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 86 | saveAsMock.mockImplementation(jest.fn()) 87 | 88 | render(); 89 | 90 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 91 | 92 | const linkElement = screen.getAllByText("Export")[0] 93 | 94 | expect(linkElement).toBeInTheDocument() 95 | 96 | userEvent.click(linkElement) 97 | 98 | await waitFor(() => { 99 | expect(linkElement).toHaveAttribute("disabled") 100 | }) 101 | 102 | await waitFor(() => { 103 | expect(linkElement).toBeEnabled 104 | }) 105 | 106 | await waitFor(() => { 107 | expect(linkElement).toBeDisabled 108 | }) 109 | 110 | await waitFor(() => { 111 | expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates 112 | ['https://api.spotify.com/v1/me'], 113 | ['https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20'], 114 | ['https://api.spotify.com/v1/me/tracks'], 115 | ['https://api.spotify.com/v1/me/tracks?offset=0&limit=50'] 116 | ]) 117 | }) 118 | 119 | await waitFor(() => { 120 | expect(saveAsMock).toHaveBeenCalledTimes(1) 121 | }) 122 | 123 | expect(saveAsMock).toHaveBeenCalledWith( 124 | { 125 | content: [ 126 | `${baseTrackHeaders}\n` + 127 | `${baseTrackDataCrying}\n` 128 | ], 129 | options: { type: 'text/csv;charset=utf-8' } 130 | }, 131 | 'liked.csv', 132 | { "autoBom": false } 133 | ) 134 | }) 135 | 136 | test("including additional artist data", async () => { 137 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 138 | saveAsMock.mockImplementation(jest.fn()) 139 | 140 | render(); 141 | 142 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 143 | 144 | const linkElement = screen.getAllByText("Export")[0] 145 | 146 | expect(linkElement).toBeInTheDocument() 147 | 148 | userEvent.click(linkElement) 149 | 150 | await waitFor(() => { 151 | expect(linkElement).toHaveAttribute("disabled") 152 | }) 153 | 154 | await waitFor(() => { 155 | expect(linkElement).toBeEnabled 156 | }) 157 | 158 | await waitFor(() => { 159 | expect(linkElement).toBeDisabled 160 | }) 161 | 162 | await waitFor(() => { 163 | expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates 164 | ['https://api.spotify.com/v1/me'], 165 | ['https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20'], 166 | ['https://api.spotify.com/v1/me/tracks'], 167 | ['https://api.spotify.com/v1/me/tracks?offset=0&limit=50'], 168 | ['https://api.spotify.com/v1/artists?ids=4TXdHyuAOl3rAOFmZ6MeKz'] 169 | ]) 170 | }) 171 | 172 | await waitFor(() => { 173 | expect(saveAsMock).toHaveBeenCalledTimes(1) 174 | }) 175 | 176 | expect(saveAsMock).toHaveBeenCalledWith( 177 | { 178 | content: [ 179 | `${baseTrackHeaders},"Artist Genres"\n` + 180 | `${baseTrackDataCrying},"nottingham indie"\n` 181 | ], 182 | options: { type: 'text/csv;charset=utf-8' } 183 | }, 184 | 'liked.csv', 185 | { "autoBom": false } 186 | ) 187 | }) 188 | 189 | test("including additional audio features data", async () => { 190 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 191 | saveAsMock.mockImplementation(jest.fn()) 192 | 193 | render(); 194 | 195 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 196 | 197 | const linkElement = screen.getAllByText("Export")[0] 198 | 199 | expect(linkElement).toBeInTheDocument() 200 | 201 | userEvent.click(linkElement) 202 | 203 | await waitFor(() => { 204 | expect(linkElement).toHaveAttribute("disabled") 205 | }) 206 | 207 | await waitFor(() => { 208 | expect(linkElement).toBeEnabled 209 | }) 210 | 211 | await waitFor(() => { 212 | expect(linkElement).toBeDisabled 213 | }) 214 | 215 | await waitFor(() => { 216 | expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates 217 | ['https://api.spotify.com/v1/me'], 218 | ['https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20'], 219 | ['https://api.spotify.com/v1/me/tracks'], 220 | ['https://api.spotify.com/v1/me/tracks?offset=0&limit=50'], 221 | ['https://api.spotify.com/v1/audio-features?ids=1GrLfs4TEvAZ86HVzXHchS'] 222 | ]) 223 | }) 224 | 225 | await waitFor(() => { 226 | expect(saveAsMock).toHaveBeenCalledTimes(1) 227 | }) 228 | 229 | expect(saveAsMock).toHaveBeenCalledWith( 230 | { 231 | content: [ 232 | `${baseTrackHeaders},"Danceability","Energy","Key","Loudness","Mode","Speechiness","Acousticness","Instrumentalness","Liveness","Valence","Tempo","Time Signature"\n` + 233 | `${baseTrackDataCrying},"0.416","0.971","0","-5.55","1","0.0575","0.00104","0.0391","0.44","0.19","131.988","4"\n` 234 | ], 235 | options: { type: 'text/csv;charset=utf-8' } 236 | }, 237 | 'liked.csv', 238 | { "autoBom": false } 239 | ) 240 | }) 241 | 242 | test("including additional album data", async () => { 243 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 244 | saveAsMock.mockImplementation(jest.fn()) 245 | 246 | render(); 247 | 248 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 249 | 250 | const linkElement = screen.getAllByText("Export")[0] 251 | 252 | expect(linkElement).toBeInTheDocument() 253 | 254 | userEvent.click(linkElement) 255 | 256 | await waitFor(() => { 257 | expect(linkElement).toHaveAttribute("disabled") 258 | }) 259 | 260 | await waitFor(() => { 261 | expect(linkElement).toBeEnabled 262 | }) 263 | 264 | await waitFor(() => { 265 | expect(linkElement).toBeDisabled 266 | }) 267 | 268 | await waitFor(() => { 269 | expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates 270 | ['https://api.spotify.com/v1/me'], 271 | ['https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20'], 272 | ['https://api.spotify.com/v1/me/tracks'], 273 | ['https://api.spotify.com/v1/me/tracks?offset=0&limit=50'], 274 | ['https://api.spotify.com/v1/albums?ids=4iwv7b8gDPKztLkKCbWyhi'] 275 | ]) 276 | }) 277 | 278 | await waitFor(() => { 279 | expect(saveAsMock).toHaveBeenCalledTimes(1) 280 | }) 281 | 282 | expect(saveAsMock).toHaveBeenCalledWith( 283 | { 284 | content: [ 285 | `${baseTrackHeaders},"Album Genres","Label","Copyrights"\n` + 286 | `${baseTrackDataCrying},"something, something else","Beggars Banquet","C 2016 Beggars Banquet Records Ltd., P 2016 Beggars Banquet Records Ltd."\n` 287 | ], 288 | options: { type: 'text/csv;charset=utf-8' } 289 | }, 290 | 'liked.csv', 291 | { "autoBom": false } 292 | ) 293 | }) 294 | 295 | test("tracks without album data omit it", async () => { 296 | server.use(...nullAlbumHandlers) 297 | 298 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 299 | saveAsMock.mockImplementation(jest.fn()) 300 | 301 | render(); 302 | 303 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 304 | 305 | const linkElement = screen.getAllByText("Export")[0] 306 | 307 | expect(linkElement).toBeInTheDocument() 308 | 309 | userEvent.click(linkElement) 310 | 311 | await waitFor(() => { 312 | expect(linkElement).toHaveAttribute("disabled") 313 | }) 314 | 315 | await waitFor(() => { 316 | expect(linkElement).toBeEnabled 317 | }) 318 | 319 | await waitFor(() => { 320 | expect(linkElement).toBeDisabled 321 | }) 322 | 323 | await waitFor(() => { 324 | expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates 325 | ['https://api.spotify.com/v1/me'], 326 | ['https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20'], 327 | ['https://api.spotify.com/v1/me/tracks'], 328 | ['https://api.spotify.com/v1/me/tracks?offset=0&limit=50'], 329 | ['https://api.spotify.com/v1/albums?ids=4iwv7b8gDPKztLkKCbWyhi'] 330 | ]) 331 | }) 332 | 333 | await waitFor(() => { 334 | expect(saveAsMock).toHaveBeenCalledTimes(1) 335 | }) 336 | 337 | expect(saveAsMock).toHaveBeenCalledWith( 338 | { 339 | content: [ 340 | `${baseTrackHeaders},"Album Genres","Label","Copyrights"\n` + 341 | `${baseTrackDataCrying},"","",""\n` 342 | ], 343 | options: { type: 'text/csv;charset=utf-8' } 344 | }, 345 | 'liked.csv', 346 | { "autoBom": false } 347 | ) 348 | }) 349 | 350 | test("playlist with null track skips null track", async () => { 351 | server.use(...nullTrackHandlers) 352 | 353 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 354 | saveAsMock.mockImplementation(jest.fn()) 355 | 356 | render(); 357 | 358 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 359 | 360 | const linkElement = screen.getAllByText("Export")[1] 361 | 362 | expect(linkElement).toBeInTheDocument() 363 | 364 | userEvent.click(linkElement) 365 | 366 | await waitFor(() => { 367 | expect(saveAsMock).toHaveBeenCalledTimes(1) 368 | }) 369 | 370 | expect(saveAsMock).toHaveBeenCalledWith( 371 | { 372 | content: [ 373 | `${baseTrackHeaders}\n` 374 | ], 375 | options: { type: 'text/csv;charset=utf-8' } 376 | }, 377 | 'ghostpoet_–_peanut_butter_blues_and_melancholy_jam.csv', 378 | { "autoBom": false } 379 | ) 380 | }) 381 | 382 | test("playlist with local tracks includes them", async () => { 383 | server.use(...localTrackHandlers) 384 | 385 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 386 | saveAsMock.mockImplementation(jest.fn()) 387 | 388 | render(); 389 | 390 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 391 | 392 | const linkElement = screen.getAllByText("Export")[1] 393 | 394 | expect(linkElement).toBeInTheDocument() 395 | 396 | userEvent.click(linkElement) 397 | 398 | await waitFor(() => { 399 | expect(saveAsMock).toHaveBeenCalledTimes(1) 400 | }) 401 | 402 | expect(saveAsMock).toHaveBeenCalledWith( 403 | { 404 | content: [ 405 | `${baseTrackHeaders}\n` + 406 | '"spotify:local:The+Waymores:Heart+of+Stone:Heart+of+Stone:128","Heart of Stone","","The Waymores","","Heart of Stone","","","","","0","0","128000","","false","0","","spotify:user:u8ins5esg43wtxk4h66o5d1nb","2021-02-24T06:12:40Z"\n' + 407 | '"spotify:local:Charlie+Marie:Heard+It+Through+The+Red+Wine:Heard+It+Through+The+Red+Wine:227","Heard It Through The Red Wine","","Charlie Marie","","Heard It Through The Red Wine","","","","","0","0","227000","","false","0","","spotify:user:u8ins5esg43wtxk4h66o5d1nb","2021-02-24T06:12:40Z"\n' 408 | ], 409 | options: { type: 'text/csv;charset=utf-8' } 410 | }, 411 | 'ghostpoet_–_peanut_butter_blues_and_melancholy_jam.csv', 412 | { "autoBom": false } 413 | ) 414 | }) 415 | 416 | test("playlist with duplicate tracks includes them", async () => { 417 | server.use(...duplicateTrackHandlers) 418 | 419 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 420 | saveAsMock.mockImplementation(jest.fn()) 421 | 422 | render(); 423 | 424 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 425 | 426 | const linkElement = screen.getAllByText("Export")[1] 427 | 428 | expect(linkElement).toBeInTheDocument() 429 | 430 | userEvent.click(linkElement) 431 | 432 | await waitFor(() => { 433 | expect(saveAsMock).toHaveBeenCalledTimes(1) 434 | }) 435 | 436 | expect(saveAsMock).toHaveBeenCalledWith( 437 | { 438 | content: [ 439 | `${baseTrackHeaders}\n` + 440 | '"spotify:track:7ATyvp3TmYBmGW7YuC8DJ3","One Twos / Run Run Run","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","2011","https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80","1","1","241346","https://p.scdn.co/mp3-preview/137d431ad0cf987b147dccea6304aca756e923c1?cid=9950ac751e34487dbbe027c4fd7f8e99","false","22","GBMEF1100339","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n' + 441 | '"spotify:track:7ATyvp3TmYBmGW7YuC8DJ3","One Twos / Run Run Run","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","2011","https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80","1","1","241346","https://p.scdn.co/mp3-preview/137d431ad0cf987b147dccea6304aca756e923c1?cid=9950ac751e34487dbbe027c4fd7f8e99","false","22","GBMEF1100339","spotify:user:watsonbox","2020-11-20T15:19:04Z"\n' 442 | ], 443 | options: { type: 'text/csv;charset=utf-8' } 444 | }, 445 | 'ghostpoet_–_peanut_butter_blues_and_melancholy_jam.csv', 446 | { "autoBom": false } 447 | ) 448 | }) 449 | }) 450 | 451 | describe("searching playlists", () => { 452 | test("simple successful search", async () => { 453 | render() 454 | 455 | expect(await screen.findByRole('searchbox')).toBeInTheDocument() 456 | 457 | userEvent.type(screen.getByRole('searchbox'), 'Ghost{enter}') 458 | 459 | await waitFor(() => { 460 | // Liked tracks is gone but Ghostpoet still matches 461 | expect(screen.queryAllByRole('row')).toHaveLength(2) 462 | expect(screen.queryByText("Liked")).not.toBeInTheDocument() 463 | expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).toBeInTheDocument() 464 | }) 465 | 466 | userEvent.type(screen.getByRole('searchbox'), '{Escape}') 467 | 468 | await waitFor(() => { 469 | // Both liked tracks and Ghostpoet are present 470 | expect(screen.queryAllByRole('row')).toHaveLength(3) 471 | expect(screen.queryByText("Liked")).toBeInTheDocument() 472 | expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).toBeInTheDocument() 473 | }) 474 | }) 475 | 476 | test("search with no results", async () => { 477 | render() 478 | 479 | expect(await screen.findByRole('searchbox')).toBeInTheDocument() 480 | 481 | userEvent.type(screen.getByRole('searchbox'), 'test{enter}') 482 | 483 | await waitFor(() => { 484 | // Both liked tracks and Ghostpoet are missing 485 | expect(screen.queryAllByRole('row')).toHaveLength(1) 486 | expect(screen.queryByText("Liked")).not.toBeInTheDocument() 487 | expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).not.toBeInTheDocument() 488 | }) 489 | }) 490 | }) 491 | 492 | describe("missing playlists", () => { 493 | test("playlist loading", async () => { 494 | server.use(...missingPlaylistsHandlers) 495 | 496 | render() 497 | 498 | expect(await screen.findByText(/This playlist is not supported/)).toBeInTheDocument() // FIXME 499 | expect(await screen.queryAllByRole('row')).toHaveLength(4) 500 | }) 501 | 502 | test("exporting of all playlists", async () => { 503 | server.use(...missingPlaylistsHandlers) 504 | 505 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 506 | saveAsMock.mockImplementation(jest.fn()) 507 | 508 | const jsZipFileMock = jest.spyOn(JSZip.prototype, 'file') 509 | const jsZipGenerateAsync = jest.spyOn(JSZip.prototype, 'generateAsync') 510 | jsZipGenerateAsync.mockResolvedValue("zip_content") 511 | 512 | render(); 513 | 514 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 515 | 516 | const linkElement = screen.getByText("Export All") 517 | 518 | expect(linkElement).toBeInTheDocument() 519 | 520 | userEvent.click(linkElement) 521 | 522 | await waitFor(() => { 523 | expect(jsZipFileMock).toHaveBeenCalledTimes(2) 524 | }) 525 | }) 526 | 527 | // FIXME: Repeated searches producing extra request 528 | test("searching", async () => { 529 | server.use(...missingPlaylistsHandlers) 530 | 531 | render() 532 | 533 | expect(await screen.findByRole('searchbox')).toBeInTheDocument() 534 | 535 | userEvent.type(screen.getByRole('searchbox'), 'Ghost{enter}') 536 | 537 | await waitFor(() => { 538 | expect(screen.queryAllByRole('row')).toHaveLength(2) 539 | }) 540 | }) 541 | }) 542 | 543 | test("exporting of all playlists", async () => { 544 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 545 | saveAsMock.mockImplementation(jest.fn()) 546 | 547 | const jsZipFileMock = jest.spyOn(JSZip.prototype, 'file') 548 | const jsZipGenerateAsync = jest.spyOn(JSZip.prototype, 'generateAsync') 549 | jsZipGenerateAsync.mockResolvedValue("zip_content") 550 | 551 | render(); 552 | 553 | expect(await screen.findByText(/Export All/)).toBeInTheDocument() 554 | 555 | const linkElement = screen.getByText("Export All") 556 | 557 | expect(linkElement).toBeInTheDocument() 558 | 559 | userEvent.click(linkElement) 560 | 561 | await waitFor(() => { 562 | expect(jsZipFileMock).toHaveBeenCalledTimes(2) 563 | }) 564 | 565 | expect(jsZipFileMock).toHaveBeenCalledWith( 566 | "liked.csv", 567 | `${baseTrackHeaders}\n` + 568 | `${baseTrackDataCrying}\n` 569 | ) 570 | 571 | expect(jsZipFileMock).toHaveBeenCalledWith( 572 | "ghostpoet_–_peanut_butter_blues_and_melancholy_jam.csv", 573 | `${baseTrackHeaders}\n` + 574 | '"spotify:track:7ATyvp3TmYBmGW7YuC8DJ3","One Twos / Run Run Run","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","2011","https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80","1","1","241346","https://p.scdn.co/mp3-preview/137d431ad0cf987b147dccea6304aca756e923c1?cid=9950ac751e34487dbbe027c4fd7f8e99","false","22","GBMEF1100339","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n' + 575 | '"spotify:track:0FNanBLvmFEDyD75Whjj52","Us Against Whatever Ever","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","2011","https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80","1","2","269346","https://p.scdn.co/mp3-preview/e5e39be10697be8755532d02c52319ffa6d58688?cid=9950ac751e34487dbbe027c4fd7f8e99","false","36","GBMEF1000270","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n' 576 | ) 577 | 578 | await waitFor(() => { 579 | expect(saveAsMock).toHaveBeenCalledTimes(1) 580 | }) 581 | 582 | expect(saveAsMock).toHaveBeenCalledWith("zip_content", "spotify_playlists.zip") 583 | }) 584 | 585 | test("exporting of search results", async () => { 586 | const saveAsMock = jest.spyOn(FileSaver, "saveAs") 587 | saveAsMock.mockImplementation(jest.fn()) 588 | 589 | const jsZipFileMock = jest.spyOn(JSZip.prototype, 'file') 590 | const jsZipGenerateAsync = jest.spyOn(JSZip.prototype, 'generateAsync') 591 | jsZipGenerateAsync.mockResolvedValue("zip_content") 592 | 593 | render(); 594 | 595 | expect(await screen.findByRole('searchbox')).toBeInTheDocument() 596 | 597 | userEvent.type(screen.getByRole('searchbox'), 'Ghost{enter}') 598 | 599 | expect(await screen.findByText(/Export Results/)).toBeInTheDocument() 600 | 601 | const linkElement = screen.getByText("Export Results") 602 | 603 | expect(linkElement).toBeInTheDocument() 604 | 605 | userEvent.click(linkElement) 606 | 607 | await waitFor(() => { 608 | expect(jsZipFileMock).toHaveBeenCalledTimes(1) 609 | }) 610 | 611 | expect(jsZipFileMock).toHaveBeenCalledWith( 612 | "ghostpoet_–_peanut_butter_blues_and_melancholy_jam.csv", 613 | `${baseTrackHeaders}\n` + 614 | '"spotify:track:7ATyvp3TmYBmGW7YuC8DJ3","One Twos / Run Run Run","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","2011","https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80","1","1","241346","https://p.scdn.co/mp3-preview/137d431ad0cf987b147dccea6304aca756e923c1?cid=9950ac751e34487dbbe027c4fd7f8e99","false","22","GBMEF1100339","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n' + 615 | '"spotify:track:0FNanBLvmFEDyD75Whjj52","Us Against Whatever Ever","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","2011","https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80","1","2","269346","https://p.scdn.co/mp3-preview/e5e39be10697be8755532d02c52319ffa6d58688?cid=9950ac751e34487dbbe027c4fd7f8e99","false","36","GBMEF1000270","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n' 616 | ) 617 | 618 | await waitFor(() => { 619 | expect(saveAsMock).toHaveBeenCalledTimes(1) 620 | }) 621 | 622 | expect(saveAsMock).toHaveBeenCalledWith("zip_content", "spotify_playlists.zip") 623 | }) 624 | -------------------------------------------------------------------------------- /src/components/PlaylistTable.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { withTranslation, WithTranslation, Translation } from "react-i18next" 3 | import { ProgressBar } from "react-bootstrap" 4 | 5 | import Bugsnag from "@bugsnag/js" 6 | import PlaylistsData from "./data/PlaylistsData" 7 | import ConfigDropdown, { ConfigDropdownRef } from "./ConfigDropdown" 8 | import PlaylistSearch, { PlaylistSearchRef } from "./PlaylistSearch" 9 | import PlaylistRow from "./PlaylistRow" 10 | import Paginator from "./Paginator" 11 | import PlaylistsExporter from "./PlaylistsExporter" 12 | import { apiCall, apiCallErrorHandler } from "helpers" 13 | 14 | interface PlaylistTableProps extends WithTranslation { 15 | accessToken: string, 16 | config?: any, 17 | onSetSubtitle: (subtitile: React.JSX.Element) => void 18 | } 19 | 20 | class PlaylistTable extends React.Component { 21 | PAGE_SIZE = 20 22 | 23 | userId?: string 24 | playlistsData?: PlaylistsData 25 | configDropdown = React.createRef() 26 | playlistSearch = React.createRef() 27 | 28 | state = { 29 | initialized: false, 30 | searchQuery: "", 31 | playlists: [], 32 | playlistCount: 0, 33 | likedSongs: { 34 | limit: 0, 35 | count: 0 36 | }, 37 | currentPage: 1, 38 | progressBar: { 39 | show: false, 40 | label: "", 41 | value: 0 42 | }, 43 | config: { 44 | includeArtistsData: false, 45 | includeAudioFeaturesData: false, 46 | includeAlbumData: false 47 | } 48 | } 49 | 50 | constructor(props: PlaylistTableProps) { 51 | super(props) 52 | 53 | if (props.config) { 54 | this.state.config = props.config 55 | } 56 | } 57 | 58 | handlePlaylistSearch = async (query: string) => { 59 | if (query.length === 0) { 60 | this.handlePlaylistSearchCancel() 61 | return 62 | } 63 | 64 | const playlists = await this.playlistsData!.search(query).catch(apiCallErrorHandler) 65 | 66 | this.setState({ 67 | searchQuery: query, 68 | playlists: playlists, 69 | playlistCount: playlists!.length, 70 | currentPage: 1, 71 | progressBar: { 72 | show: false 73 | } 74 | }) 75 | 76 | let key = "subtitle_search" 77 | if (query.startsWith("public:") || query.startsWith("collaborative:") || query.startsWith("owner:")) { 78 | key += "_advanced" 79 | } 80 | 81 | this.props.onSetSubtitle({(t) => t(key, { total: playlists!.length, query: query })}) 82 | } 83 | 84 | handlePlaylistSearchCancel = () => { 85 | return this.loadCurrentPlaylistPage().catch(apiCallErrorHandler) 86 | } 87 | 88 | loadCurrentPlaylistPage = async () => { 89 | if (this.playlistSearch.current) { 90 | this.playlistSearch.current.clear() 91 | } 92 | 93 | try { 94 | const playlists = await this.playlistsData!.slice( 95 | ((this.state.currentPage - 1) * this.PAGE_SIZE), 96 | ((this.state.currentPage - 1) * this.PAGE_SIZE) + this.PAGE_SIZE 97 | ) 98 | 99 | // FIXME: Handle unmounting 100 | this.setState( 101 | { 102 | initialized: true, 103 | searchQuery: "", 104 | playlists: playlists, 105 | playlistCount: await this.playlistsData!.total(), 106 | progressBar: { 107 | show: false 108 | } 109 | }, 110 | () => { 111 | const min = ((this.state.currentPage - 1) * this.PAGE_SIZE) + 1 112 | const max = Math.min(min + this.PAGE_SIZE - 1, this.state.playlistCount) 113 | this.props.onSetSubtitle( 114 | {(t) => t("subtitle", { min: min, max: max, total: this.state.playlistCount, userId: this.userId })} 115 | ) 116 | } 117 | ) 118 | } catch (error) { 119 | apiCallErrorHandler(error) 120 | } 121 | } 122 | 123 | handlePlaylistsLoadingStarted = () => { 124 | Bugsnag.leaveBreadcrumb("Started exporting all playlists") 125 | 126 | this.configDropdown.current!.spin(true) 127 | } 128 | 129 | handlePlaylistsLoadingDone = () => { 130 | this.configDropdown.current!.spin(false) 131 | } 132 | 133 | handlePlaylistsExportDone = () => { 134 | Bugsnag.leaveBreadcrumb("Finished exporting all playlists") 135 | 136 | this.setState({ 137 | progressBar: { 138 | show: true, 139 | label: this.props.i18n.t("exporting_done"), 140 | value: this.state.playlistCount 141 | } 142 | }) 143 | } 144 | 145 | handlePlaylistExportStarted = (playlistName: string, doneCount: number) => { 146 | Bugsnag.leaveBreadcrumb(`Started exporting playlist ${playlistName}`) 147 | 148 | this.setState({ 149 | progressBar: { 150 | show: true, 151 | label: this.props.i18n.t("exporting_playlist", { playlistName: playlistName }), 152 | value: doneCount 153 | } 154 | }) 155 | } 156 | 157 | handleConfigChanged = (config: any) => { 158 | Bugsnag.leaveBreadcrumb(`Config updated to ${JSON.stringify(config)}`) 159 | 160 | this.setState({ config: config }) 161 | } 162 | 163 | handlePageChanged = (page: number) => { 164 | try { 165 | this.setState( 166 | { currentPage: page }, 167 | this.loadCurrentPlaylistPage 168 | ) 169 | } catch (error) { 170 | apiCallErrorHandler(error) 171 | } 172 | } 173 | 174 | async componentDidMount() { 175 | try { 176 | const user = await apiCall("https://api.spotify.com/v1/me", this.props.accessToken) 177 | .then(response => response.data) 178 | 179 | Bugsnag.setUser(user.id, user.uri, user.display_name) 180 | 181 | this.userId = user.id 182 | this.playlistsData = new PlaylistsData( 183 | this.props.accessToken, 184 | this.userId!, 185 | this.handlePlaylistsLoadingStarted, 186 | this.handlePlaylistsLoadingDone 187 | ) 188 | 189 | await this.loadCurrentPlaylistPage() 190 | } catch (error) { 191 | apiCallErrorHandler(error) 192 | } 193 | } 194 | 195 | render() { 196 | const progressBar = 197 | 198 | if (this.state.initialized) { 199 | return ( 200 |
201 |
202 | 203 | 204 | 205 | {this.state.progressBar.show && progressBar} 206 |
207 |
208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 227 | 228 | 229 | 230 | {this.state.playlists.map((playlist: any, i) => { 231 | return 237 | })} 238 | 239 |
{this.props.i18n.t("playlist.name")}{this.props.i18n.t("playlist.owner")}{this.props.i18n.t("playlist.tracks")}{this.props.i18n.t("playlist.public")}{this.props.i18n.t("playlist.collaborative")} 218 | 226 |
240 |
241 |
242 | 243 |
244 |
245 | ); 246 | } else { 247 | return
248 | } 249 | } 250 | } 251 | 252 | export default withTranslation()(PlaylistTable) 253 | -------------------------------------------------------------------------------- /src/components/PlaylistsExporter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { withTranslation, WithTranslation } from "react-i18next" 3 | import { Button } from "react-bootstrap" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | import { saveAs } from "file-saver" 6 | import JSZip from "jszip" 7 | 8 | import PlaylistExporter from "./PlaylistExporter" 9 | import { apiCallErrorHandler } from "helpers" 10 | import PlaylistsData from "./data/PlaylistsData" 11 | 12 | interface PlaylistsExporterProps extends WithTranslation { 13 | accessToken: string 14 | playlistsData: PlaylistsData 15 | searchQuery: string 16 | config: any 17 | onPlaylistExportStarted: (playlistName: string, doneCount: number) => void 18 | onPlaylistsExportDone: () => void 19 | } 20 | 21 | // Handles exporting all playlist data as a zip file 22 | class PlaylistsExporter extends React.Component { 23 | state = { 24 | exporting: false 25 | } 26 | 27 | async export(accessToken: string, playlistsData: PlaylistsData, searchQuery: string, config: any) { 28 | let playlistFileNames = new Set() 29 | let playlistCsvExports = new Array() 30 | 31 | const playlists = searchQuery === "" ? await playlistsData.all() : await playlistsData.search(searchQuery) 32 | 33 | let doneCount = 0 34 | 35 | for (const playlist of playlists) { 36 | this.props.onPlaylistExportStarted(playlist.name, doneCount) 37 | 38 | let exporter = new PlaylistExporter(accessToken, playlist, config) 39 | let csvData = await exporter.csvData() 40 | let fileName = exporter.fileName(false) 41 | 42 | for (let i = 1; playlistFileNames.has(fileName + exporter.fileExtension()); i++) { 43 | fileName = exporter.fileName(false) + ` (${i})` 44 | } 45 | 46 | playlistFileNames.add(fileName + exporter.fileExtension()) 47 | playlistCsvExports.push(csvData) 48 | 49 | doneCount++ 50 | } 51 | 52 | this.props.onPlaylistsExportDone() 53 | 54 | var zip = new JSZip() 55 | 56 | Array.from(playlistFileNames).forEach(function (fileName, i) { 57 | zip.file(fileName, playlistCsvExports[i]) 58 | }) 59 | 60 | zip.generateAsync({ type: "blob" }).then(function (content) { 61 | saveAs(content, "spotify_playlists.zip"); 62 | }) 63 | } 64 | 65 | exportPlaylists = () => { 66 | this.setState( 67 | { exporting: true }, 68 | () => { 69 | this.export( 70 | this.props.accessToken, 71 | this.props.playlistsData, 72 | this.props.searchQuery, 73 | this.props.config 74 | ).catch(apiCallErrorHandler).then(() => { 75 | this.setState({ exporting: false }) 76 | }) 77 | } 78 | ) 79 | } 80 | 81 | render() { 82 | const text = this.props.searchQuery === "" ? this.props.i18n.t("export_all") : this.props.i18n.t("export_search_results") 83 | 84 | // @ts-ignore 85 | return 88 | } 89 | } 90 | 91 | export default withTranslation()(PlaylistsExporter) 92 | -------------------------------------------------------------------------------- /src/components/TopMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { withTranslation, WithTranslation, Trans } from "react-i18next" 3 | import { Button, Modal, Table, Dropdown, Form } from "react-bootstrap" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | 6 | interface TopMenuProps extends WithTranslation { 7 | loggedIn: boolean 8 | } 9 | 10 | class TopMenu extends React.Component { 11 | state = { 12 | showHelp: false 13 | } 14 | 15 | handleToggleHelp = () => { 16 | this.setState({ showHelp: !this.state.showHelp }) 17 | } 18 | 19 | handleLogoutClick = () => { 20 | window.location.href = `${window.location.href.split('#')[0]}?change_user=true` 21 | } 22 | 23 | handleDarkModeClick = () => { 24 | this.setStoredTheme(this.getPreferredTheme() === "dark" ? "light" : "dark") 25 | this.setTheme(this.getPreferredTheme()) 26 | } 27 | 28 | handleLanguageSwitch = (language: string) => { 29 | this.props.i18n.changeLanguage(language) 30 | } 31 | 32 | getStoredTheme = () => localStorage.getItem('theme') 33 | setStoredTheme = (theme: string) => localStorage.setItem('theme', theme) 34 | 35 | getPreferredTheme = () => { 36 | const storedTheme = this.getStoredTheme() 37 | if (storedTheme) { 38 | return storedTheme 39 | } 40 | 41 | return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 42 | } 43 | 44 | setTheme = (theme: string) => { 45 | document.documentElement.setAttribute('data-bs-theme', theme) 46 | } 47 | 48 | componentDidMount() { 49 | this.setTheme(this.getPreferredTheme()) 50 | } 51 | 52 | renderLanguageDropdownItem = (language: string, label: string) => ( 53 | this.handleLanguageSwitch(language)}> 54 | 55 | {label} 56 | 57 | 58 | ) 59 | 60 | render() { 61 | const helpButton = this.props.loggedIn ? ( 62 | <> 63 | 66 | 67 | 68 | {this.props.i18n.t("help.title")} 69 | 70 | 71 |
{this.props.i18n.t("help.search_syntax.title")}
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 |
{this.props.i18n.t("help.search_syntax.query")}{this.props.i18n.t("help.search_syntax.behavior")}
public:true{this.props.i18n.t("help.search_syntax.public_true")}
public:false{this.props.i18n.t("help.search_syntax.public_true")}
collaborative:true{this.props.i18n.t("help.search_syntax.collaborative_true")}
collaborative:false{this.props.i18n.t("help.search_syntax.collaborative_false")}
owner:me{this.props.i18n.t("help.search_syntax.owner_me")}
owner:[owner] }} />
106 | 107 | {/* eslint-disable-next-line*/} 108 |

}} />

109 |
110 |
111 | 112 | ) : '' 113 | 114 | const logoutButton = this.props.loggedIn ? : '' 117 | 118 | return ( 119 |
120 | {helpButton} 121 | 124 | 125 | 126 | 127 | 128 | 129 | {this.renderLanguageDropdownItem("en", "English")} 130 | {this.renderLanguageDropdownItem("de", "Deutsch")} 131 | {this.renderLanguageDropdownItem("es", "Español")} 132 | {this.renderLanguageDropdownItem("fr", "Français")} 133 | {this.renderLanguageDropdownItem("it", "Italiano")} 134 | {this.renderLanguageDropdownItem("nl", "Nederlands")} 135 | {this.renderLanguageDropdownItem("pt", "Português")} 136 | {this.renderLanguageDropdownItem("sv", "Svenska")} 137 | {this.renderLanguageDropdownItem("ar", "العربية")} 138 | {this.renderLanguageDropdownItem("ja", "日本語")} 139 | {this.renderLanguageDropdownItem("tr", "Türkçe")} 140 | 141 | 142 | {logoutButton} 143 |
144 | ) 145 | } 146 | } 147 | 148 | export default withTranslation()(TopMenu) 149 | -------------------------------------------------------------------------------- /src/components/__snapshots__/PlaylistTable.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`playlist loading 1`] = ` 4 | 5 |
8 |
11 | 49 | 82 | 108 |
109 |
112 | 115 | 116 | 117 | 125 | 130 | 135 | 140 | 145 | 170 | 171 | 172 | 173 | 174 | 192 | 199 | 206 | 211 | 230 | 249 | 274 | 275 | 276 | 293 | 300 | 307 | 312 | 331 | 350 | 375 | 376 | 377 |
120 | 123 | Name 124 | 128 | Owner 129 | 133 | Tracks 134 | 138 | Public? 139 | 143 | Collaborative? 144 | 148 | 169 |
175 | 191 | 193 | 196 | Liked 197 | 198 | 200 | 203 | watsonbox 204 | 205 | 209 | 1 210 | 214 | 229 | 233 | 248 | 252 | 273 |
277 | 292 | 294 | 297 | Ghostpoet – Peanut Butter Blues and Melancholy Jam 298 | 299 | 301 | 304 | watsonbox 305 | 306 | 310 | 10 311 | 315 | 330 | 334 | 349 | 353 | 374 |
378 |
379 |
382 | 420 |
421 |
422 |
423 | `; 424 | -------------------------------------------------------------------------------- /src/components/data/PlaylistsData.ts: -------------------------------------------------------------------------------- 1 | import { apiCall } from "helpers" 2 | 3 | // Handles cached loading of all or subsets of playlist data 4 | class PlaylistsData { 5 | PLAYLIST_LIMIT = 50 6 | PLACEHOLDER = {} 7 | 8 | userId: string 9 | private accessToken: string 10 | private onPlaylistsLoadingStarted?: () => void 11 | private onPlaylistsLoadingDone?: () => void 12 | private data: any[] 13 | private likedTracksPlaylist: any 14 | private dataInitialized = false 15 | 16 | constructor(accessToken: string, userId: string, onPlaylistsLoadingStarted?: () => void, onPlaylistsLoadingDone?: () => void) { 17 | this.accessToken = accessToken 18 | this.userId = userId 19 | this.onPlaylistsLoadingStarted = onPlaylistsLoadingStarted 20 | this.onPlaylistsLoadingDone = onPlaylistsLoadingDone 21 | this.data = [] 22 | this.likedTracksPlaylist = null 23 | } 24 | 25 | async total() { 26 | if (!this.dataInitialized) { 27 | await this.loadSlice() 28 | } 29 | 30 | return this.data.filter(p => p).length 31 | } 32 | 33 | async slice(start: number, end: number) { 34 | await this.loadSlice(start, end) 35 | await this.loadLikedTracksPlaylist() 36 | 37 | // It's a little ugly, but we slip in liked tracks with the first slice 38 | if (start === 0) { 39 | return [this.likedTracksPlaylist, ...this.data.slice(start, end).filter(p => p)] 40 | } else { 41 | return this.data.slice(start, end).filter(p => p) 42 | } 43 | } 44 | 45 | async all() { 46 | await this.loadAll() 47 | await this.loadLikedTracksPlaylist() 48 | 49 | // Remove any uninitialized playlists when exporting 50 | return [this.likedTracksPlaylist, ...this.data.filter(p => p && Object.keys(p).length > 0)] 51 | } 52 | 53 | async search(query: string) { 54 | await this.loadAll() 55 | 56 | // Remove any uninitialized playlists when exporting 57 | let results = this.data.filter(p => p && Object.keys(p).length > 0) 58 | 59 | if (query.startsWith("public:")) { 60 | return results.filter(p => p.public === query.endsWith(":true")) 61 | } else if (query.startsWith("collaborative:")) { 62 | return results.filter(p => p.collaborative === query.endsWith(":true")) 63 | } else if (query.startsWith("owner:")) { 64 | let owner = query.match(/owner:(.*)/)?.at(-1)?.toLowerCase() 65 | if (owner === "me") owner = this.userId 66 | 67 | return results.filter(p => p.owner).filter(p => p.owner.display_name.toLowerCase() === owner) 68 | } else { 69 | // Case-insensitive search in playlist name 70 | // TODO: Add lazy evaluation for performance? 71 | return results.filter(p => p.name.toLowerCase().includes(query.toLowerCase())) 72 | } 73 | } 74 | 75 | async loadAll() { 76 | if (this.onPlaylistsLoadingStarted) { 77 | this.onPlaylistsLoadingStarted() 78 | } 79 | 80 | await this.loadSlice() 81 | 82 | // Get the rest of them if necessary 83 | for (var offset = this.PLAYLIST_LIMIT; offset < this.data.length; offset = offset + this.PLAYLIST_LIMIT) { 84 | await this.loadSlice(offset, offset + this.PLAYLIST_LIMIT) 85 | } 86 | 87 | if (this.onPlaylistsLoadingDone) { 88 | this.onPlaylistsLoadingDone() 89 | } 90 | } 91 | 92 | private async loadSlice(start = 0, end = start + this.PLAYLIST_LIMIT) { 93 | if (this.dataInitialized) { 94 | const loadedData = this.data.slice(start, end) 95 | 96 | if (loadedData.filter(i => i != null && Object.keys(i).length === 0).length === 0) { 97 | return loadedData 98 | } 99 | } 100 | 101 | const playlistsUrl = `https://api.spotify.com/v1/users/${this.userId}/playlists?offset=${start}&limit=${end - start}` 102 | const playlistsResponse = await apiCall(playlistsUrl, this.accessToken) 103 | const playlistsData = playlistsResponse.data 104 | 105 | if (!this.dataInitialized) { 106 | this.data = Array(playlistsData.total).fill(this.PLACEHOLDER) 107 | this.dataInitialized = true 108 | } 109 | 110 | this.data.splice(start, playlistsData.items.length, ...playlistsData.items) 111 | } 112 | 113 | private async loadLikedTracksPlaylist() { 114 | if (this.likedTracksPlaylist !== null) { 115 | return 116 | } 117 | 118 | const likedTracksUrl = `https://api.spotify.com/v1/me/tracks` 119 | const likedTracksResponse = await apiCall(likedTracksUrl, this.accessToken) 120 | const likedTracksData = likedTracksResponse.data 121 | 122 | this.likedTracksPlaylist = { 123 | "id": "liked", 124 | "name": "Liked", 125 | "public": false, 126 | "collaborative": false, 127 | "owner": { 128 | "id": this.userId, 129 | "display_name": this.userId, 130 | "uri": "spotify:user:" + this.userId 131 | }, 132 | "tracks": { 133 | "href": "https://api.spotify.com/v1/me/tracks", 134 | "limit": likedTracksData.limit, 135 | "total": likedTracksData.total 136 | }, 137 | "uri": "spotify:user:" + this.userId + ":saved" 138 | } 139 | } 140 | } 141 | 142 | export default PlaylistsData 143 | -------------------------------------------------------------------------------- /src/components/data/TracksAlbumData.ts: -------------------------------------------------------------------------------- 1 | import i18n from "../../i18n/config" 2 | import TracksData from "./TracksData" 3 | import { apiCall } from "helpers" 4 | 5 | class TracksAlbumData extends TracksData { 6 | ALBUM_LIMIT = 20 7 | 8 | tracks: any[] 9 | 10 | constructor(accessToken: string, tracks: any[]) { 11 | super(accessToken) 12 | this.tracks = tracks 13 | } 14 | 15 | dataLabels() { 16 | return [ 17 | i18n.t("track.album.album_genres"), 18 | i18n.t("track.album.label"), 19 | i18n.t("track.album.copyrights") 20 | ] 21 | } 22 | 23 | async data() { 24 | const albumIds = Array.from(new Set(this.tracks.filter((track: any) => track.album.id).map((track: any) => track.album.id))) 25 | 26 | let requests = [] 27 | 28 | for (var offset = 0; offset < albumIds.length; offset = offset + this.ALBUM_LIMIT) { 29 | requests.push(`https://api.spotify.com/v1/albums?ids=${albumIds.slice(offset, offset + this.ALBUM_LIMIT)}`) 30 | } 31 | 32 | const albumPromises = requests.map((request) => apiCall(request, this.accessToken)) 33 | const albumResponses = await Promise.all(albumPromises) 34 | 35 | const albumDataById = new Map( 36 | albumResponses.flatMap((response) => response.data.albums.map((album: any) => { 37 | return [ 38 | album == null ? "" : album.id, 39 | [ 40 | album == null ? "" : album.genres.join(", "), 41 | album == null ? "" : album.label, 42 | album == null ? "" : album.copyrights.map((c: any) => `${c.type} ${c.text}`).join(", ") 43 | ] 44 | ] 45 | })) 46 | ) 47 | 48 | return new Map( 49 | this.tracks.map((track: any) => [track.uri, albumDataById.get(track.album.id) || ["", "", ""]]) 50 | ) 51 | } 52 | } 53 | 54 | export default TracksAlbumData 55 | -------------------------------------------------------------------------------- /src/components/data/TracksArtistsData.ts: -------------------------------------------------------------------------------- 1 | import i18n from "../../i18n/config" 2 | import TracksData from "./TracksData" 3 | import { apiCall } from "helpers" 4 | 5 | class TracksArtistsData extends TracksData { 6 | ARTIST_LIMIT = 50 7 | 8 | tracks: any[] 9 | 10 | constructor(accessToken: string, tracks: any[]) { 11 | super(accessToken) 12 | this.tracks = tracks 13 | } 14 | 15 | dataLabels() { 16 | return [ 17 | i18n.t("track.artist.artist_genres") 18 | ] 19 | } 20 | 21 | async data() { 22 | const artistIds = Array.from(new Set(this.tracks.flatMap((track: any) => { 23 | return track 24 | .artists 25 | .filter((a: any) => a.type === "artist") 26 | .map((a: any) => a.id) 27 | .filter((i: string) => i) 28 | }))) 29 | 30 | let requests = [] 31 | 32 | for (var offset = 0; offset < artistIds.length; offset = offset + this.ARTIST_LIMIT) { 33 | requests.push(`https://api.spotify.com/v1/artists?ids=${artistIds.slice(offset, offset + this.ARTIST_LIMIT)}`) 34 | } 35 | 36 | const artistPromises = requests.map(request => { return apiCall(request, this.accessToken) }) 37 | const artistResponses = await Promise.all(artistPromises) 38 | 39 | const artistsById = new Map(artistResponses.flatMap((response) => response.data.artists).map((artist: any) => [artist.id, artist])) 40 | 41 | return new Map(this.tracks.map((track: any) => { 42 | return [ 43 | track.uri, 44 | [ 45 | track.artists.map((a: any) => { 46 | return artistsById.has(a.id) ? artistsById.get(a.id)!.genres.filter((g: string) => g).join(',') : "" 47 | }).filter((g: string) => g).join(",") 48 | ] 49 | ] 50 | })) 51 | } 52 | } 53 | 54 | export default TracksArtistsData 55 | -------------------------------------------------------------------------------- /src/components/data/TracksAudioFeaturesData.ts: -------------------------------------------------------------------------------- 1 | import i18n from "../../i18n/config" 2 | import TracksData from "./TracksData" 3 | import { apiCall } from "helpers" 4 | 5 | class TracksAudioFeaturesData extends TracksData { 6 | AUDIO_FEATURES_LIMIT = 100 7 | 8 | tracks: any[] 9 | 10 | constructor(accessToken: string, tracks: any[]) { 11 | super(accessToken) 12 | this.tracks = tracks 13 | } 14 | 15 | dataLabels() { 16 | return [ 17 | i18n.t("track.audio_features.danceability"), 18 | i18n.t("track.audio_features.energy"), 19 | i18n.t("track.audio_features.key"), 20 | i18n.t("track.audio_features.loudness"), 21 | i18n.t("track.audio_features.mode"), 22 | i18n.t("track.audio_features.speechiness"), 23 | i18n.t("track.audio_features.acousticness"), 24 | i18n.t("track.audio_features.instrumentalness"), 25 | i18n.t("track.audio_features.liveness"), 26 | i18n.t("track.audio_features.valence"), 27 | i18n.t("track.audio_features.tempo"), 28 | i18n.t("track.audio_features.time_signature") 29 | ] 30 | } 31 | 32 | async data() { 33 | const trackIds = this.tracks.map((track: any) => track.id) 34 | 35 | let requests = [] 36 | 37 | for (var offset = 0; offset < trackIds.length; offset = offset + this.AUDIO_FEATURES_LIMIT) { 38 | requests.push(`https://api.spotify.com/v1/audio-features?ids=${trackIds.slice(offset, offset + this.AUDIO_FEATURES_LIMIT)}`) 39 | } 40 | 41 | const audioFeaturesPromises = requests.map(request => { return apiCall(request, this.accessToken) }) 42 | const audioFeatures = (await Promise.all(audioFeaturesPromises)).flatMap((response) => response.data.audio_features) 43 | 44 | const audioFeaturesData = new Map(audioFeatures.filter((af: any) => af).map((audioFeatures: any) => { 45 | return [ 46 | audioFeatures.uri, 47 | [ 48 | audioFeatures.danceability, 49 | audioFeatures.energy, 50 | audioFeatures.key, 51 | audioFeatures.loudness, 52 | audioFeatures.mode, 53 | audioFeatures.speechiness, 54 | audioFeatures.acousticness, 55 | audioFeatures.instrumentalness, 56 | audioFeatures.liveness, 57 | audioFeatures.valence, 58 | audioFeatures.tempo, 59 | audioFeatures.time_signature 60 | ] 61 | ] 62 | })) 63 | 64 | // Add empty fields where we didn't get data - can be the case for example with episodes 65 | const audioFeaturesTrackUris = Array.from(audioFeaturesData.keys()) 66 | this.tracks.filter(t => !audioFeaturesTrackUris.includes(t.uri)).forEach((track) => { 67 | audioFeaturesData.set(track.uri, ["", "", "", "", "", "", "", "", "", "", "", ""]) 68 | }) 69 | 70 | return audioFeaturesData 71 | } 72 | } 73 | 74 | export default TracksAudioFeaturesData 75 | -------------------------------------------------------------------------------- /src/components/data/TracksBaseData.ts: -------------------------------------------------------------------------------- 1 | import i18n from "../../i18n/config" 2 | import TracksData from "./TracksData" 3 | import { apiCall } from "helpers" 4 | 5 | class TracksBaseData extends TracksData { 6 | playlist: any 7 | 8 | constructor(accessToken: string, playlist: any) { 9 | super(accessToken) 10 | this.playlist = playlist 11 | } 12 | 13 | dataLabels() { 14 | return [ 15 | i18n.t("track.track_uri"), 16 | i18n.t("track.track_name"), 17 | i18n.t("track.artist_uris"), 18 | i18n.t("track.artist_names"), 19 | i18n.t("track.album_uri"), 20 | i18n.t("track.album_name"), 21 | i18n.t("track.album_artist_uris"), 22 | i18n.t("track.album_artist_names"), 23 | i18n.t("track.album_release_date"), 24 | i18n.t("track.album_image_url"), 25 | i18n.t("track.disc_number"), 26 | i18n.t("track.track_number"), 27 | i18n.t("track.track_duration"), 28 | i18n.t("track.track_preview_url"), 29 | i18n.t("track.explicit"), 30 | i18n.t("track.popularity"), 31 | i18n.t("track.isrc") 32 | ] 33 | } 34 | 35 | async trackItems() { 36 | await this.getPlaylistItems() 37 | 38 | return this.playlistItems 39 | } 40 | 41 | async data() { 42 | await this.getPlaylistItems() 43 | 44 | return new Map(this.playlistItems.map(item => { 45 | return [ 46 | item.track.uri, 47 | [ 48 | item.track.uri, 49 | item.track.name, 50 | item.track.artists.map((a: any) => { return a.uri }).join(', '), 51 | item.track.artists.map((a: any) => { return String(a.name).replace(/,/g, "\\,") }).join(', '), 52 | item.track.album.uri == null ? '' : item.track.album.uri, 53 | item.track.album.name, 54 | item.track.album.artists.map((a: any) => { return a.uri }).join(', '), 55 | item.track.album.artists.map((a: any) => { return String(a.name).replace(/,/g, "\\,") }).join(', '), 56 | item.track.album.release_date == null ? '' : item.track.album.release_date, 57 | item.track.album.images[0] == null ? '' : item.track.album.images[0].url, 58 | item.track.disc_number, 59 | item.track.track_number, 60 | item.track.duration_ms, 61 | item.track.preview_url == null ? '' : item.track.preview_url, 62 | item.track.explicit, 63 | item.track.popularity, 64 | item.track.external_ids.isrc == null ? '' : item.track.external_ids.isrc 65 | ] 66 | ] 67 | })) 68 | } 69 | 70 | // Memoization supporting multiple calls 71 | private playlistItems: any[] = [] 72 | private async getPlaylistItems() { 73 | if (this.playlistItems.length > 0) { 74 | return this.playlistItems 75 | } 76 | 77 | var requests = [] 78 | var limit = this.playlist.tracks.limit ? 50 : 100 79 | 80 | for (var offset = 0; offset < this.playlist.tracks.total; offset = offset + limit) { 81 | requests.push(`${this.playlist.tracks.href.split('?')[0]}?offset=${offset}&limit=${limit}`) 82 | } 83 | 84 | const trackPromises = requests.map(request => { return apiCall(request, this.accessToken) }) 85 | const trackResponses = await Promise.all(trackPromises) 86 | 87 | this.playlistItems = trackResponses.flatMap(response => { 88 | return response.data.items.filter((i: any) => i.track) // Exclude null track attributes 89 | }) 90 | } 91 | } 92 | 93 | export default TracksBaseData 94 | -------------------------------------------------------------------------------- /src/components/data/TracksData.ts: -------------------------------------------------------------------------------- 1 | abstract class TracksData { 2 | accessToken: string 3 | 4 | constructor(accessToken: string) { 5 | this.accessToken = accessToken 6 | } 7 | 8 | abstract dataLabels(): string[] 9 | abstract data(): Promise> 10 | } 11 | 12 | export default TracksData 13 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import Bugsnag from "@bugsnag/js" 2 | import axios from "axios" 3 | import Bottleneck from "bottleneck" 4 | 5 | // http://stackoverflow.com/a/901144/4167042 6 | export function getQueryParam(name: string) { 7 | name = name.replace(/[[]/, "\\[").replace(/[\]]/, "\\]"); 8 | var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"), 9 | results = regex.exec(window.location.search); 10 | return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " ")); 11 | } 12 | 13 | const REQUEST_RETRY_BUFFER = 1000 14 | const MAX_RATE_LIMIT_RETRIES = 2 // 3 attempts in total 15 | const MAX_ERROR_RETRIES = 2 // 3 attempts in total 16 | const limiter = new Bottleneck({ 17 | maxConcurrent: 1, 18 | minTime: 0 19 | }) 20 | 21 | limiter.on("failed", async (error, jobInfo) => { 22 | if (error.response.status === 429 && jobInfo.retryCount < MAX_RATE_LIMIT_RETRIES) { 23 | // Retry according to the indication from the server with a small buffer 24 | return ((error.response.headers["retry-after"] || 1) * 1000) + REQUEST_RETRY_BUFFER 25 | } else if (error.response.status !== 401 && error.response.status !== 429 && jobInfo.retryCount < MAX_ERROR_RETRIES) { 26 | // Log and retry any other failure once (e.g. 503/504 which sometimes occur) 27 | Bugsnag.notify( 28 | error, 29 | (event) => { 30 | event.addMetadata("response", error.response) 31 | event.addMetadata("request", error.config) 32 | event.groupingHash = "Retried Request" 33 | } 34 | ) 35 | 36 | if (error.response.status === 502) { 37 | // Try waiting a little longer to reduce problems with large playlists 38 | // https://github.com/watsonbox/exportify/issues/142 39 | return REQUEST_RETRY_BUFFER * 3 40 | } else { 41 | return REQUEST_RETRY_BUFFER 42 | } 43 | } 44 | }) 45 | 46 | export const apiCall = limiter.wrap(function(url: string, accessToken: string) { 47 | return axios.get(url, { headers: { 'Authorization': 'Bearer ' + accessToken } }) 48 | }) 49 | 50 | export function apiCallErrorHandler(error: any) { 51 | if (error.isAxiosError) { 52 | if (error.request.status === 401) { 53 | // Return to home page after auth token expiry 54 | window.location.href = window.location.href.split('#')[0] 55 | return 56 | } else if (error.request.status >= 500 && error.request.status < 600) { 57 | // Show error page when we get a 5XX that fails retries 58 | window.location.href = `${window.location.href.split('#')[0]}?spotify_error=true` 59 | return 60 | } 61 | } 62 | 63 | throw error 64 | } 65 | -------------------------------------------------------------------------------- /src/i18n/config.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next" 2 | import { initReactI18next } from "react-i18next" 3 | import LanguageDetector from "i18next-browser-languagedetector" 4 | 5 | i18n 6 | .use(initReactI18next) 7 | .use(LanguageDetector) 8 | .init({ 9 | fallbackLng: "en", 10 | interpolation: { 11 | escapeValue: false, 12 | }, 13 | resources: { 14 | de: { 15 | translations: require('./locales/de/translation.json') 16 | }, 17 | en: { 18 | translations: require('./locales/en/translation.json') 19 | }, 20 | es: { 21 | translations: require('./locales/es/translation.json') 22 | }, 23 | fr: { 24 | translations: require('./locales/fr/translation.json') 25 | }, 26 | it: { 27 | translations: require('./locales/it/translation.json') 28 | }, 29 | nl: { 30 | translations: require('./locales/nl/translation.json') 31 | }, 32 | pt: { 33 | translations: require('./locales/pt/translation.json') 34 | }, 35 | sv: { 36 | translations: require('./locales/sv/translation.json') 37 | }, 38 | ar: { 39 | translations: require('./locales/ar/translation.json') 40 | }, 41 | ja: { 42 | translations: require('./locales/ja/translation.json') 43 | }, 44 | tr: { 45 | translations: require('./locales/tr/translation.json') 46 | } 47 | }, 48 | ns: ['translations'], 49 | defaultNS: 'translations' 50 | }) 51 | 52 | export default i18n 53 | -------------------------------------------------------------------------------- /src/i18n/locales/ar/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "صدّر قوائم التشغيل الخاصة بك من Spotify.", 3 | "get_started": "ابدأ الآن", 4 | "subtitle": "{{min}}-{{max}} من {{total}} قوائم التشغيل لـ {{userId}}", 5 | "subtitle_search": "{{total}} نتيجة تحتوي على \"{{query}}\" في اسم القائمة", 6 | "subtitle_search_advanced": "{{total}} نتيجة للبحث المتقدم \"{{query}}\"", 7 | "search": "بحث", 8 | "export_all": "تصدير الكل", 9 | "exporting_done": "تم التصدير!", 10 | "exporting_playlist": "جاري تصدير {{playlistName}}...", 11 | "export_search_results": "تصدير النتائج", 12 | "top_menu": { 13 | "help": "المساعدة", 14 | "toggle_dark_mode": "تبديل الوضع الداكن", 15 | "change_language": "تغيير اللغة", 16 | "change_user": "تبديل المستخدم" 17 | }, 18 | "config": { 19 | "include_artists_data": "تضمين بيانات الفنانين", 20 | "include_audio_features_data": "تضمين خصائص الصوت", 21 | "include_album_data": "تضمين بيانات الألبومات" 22 | }, 23 | "help": { 24 | "title": "المرجع السريع", 25 | "search_syntax": { 26 | "title": "صيغة البحث المتقدم", 27 | "query": "الاستعلام", 28 | "behavior": "السلوك", 29 | "public_true": "عرض قوائم التشغيل العامة فقط", 30 | "public_false": "عرض قوائم التشغيل الخاصة فقط", 31 | "collaborative_true": "عرض قوائم التشغيل التعاونية فقط", 32 | "collaborative_false": "عدم عرض قوائم التشغيل التعاونية", 33 | "owner_me": "عرض قوائم التشغيل الخاصة بي فقط", 34 | "owner_owner": "عرض قوائم التشغيل المملوكة بواسطة [owner] فقط", 35 | "more_detail": "لمزيد من التفاصيل، يرجى الرجوع إلى الوثائق الكاملة للمشروع." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "الاسم", 40 | "owner": "المالك", 41 | "tracks": "الأغاني", 42 | "public": "عام؟", 43 | "collaborative": "تعاوني؟", 44 | "not_supported": "هذه القائمة غير مدعومة", 45 | "export": "تصدير" 46 | }, 47 | "track": { 48 | "track_uri": "رابط الأغنية (URI)", 49 | "track_name": "اسم الأغنية", 50 | "artist_uris": "روابط الفنانين (URI)", 51 | "artist_names": "أسماء الفنانين", 52 | "album_uri": "رابط الألبوم (URI)", 53 | "album_name": "اسم الألبوم", 54 | "album_artist_uris": "روابط فناني الألبوم (URI)", 55 | "album_artist_names": "أسماء فناني الألبوم", 56 | "album_release_date": "تاريخ إصدار الألبوم", 57 | "album_image_url": "رابط صورة الألبوم", 58 | "disc_number": "رقم القرص", 59 | "track_number": "رقم الأغنية", 60 | "track_duration": "مدة الأغنية (بالمللي ثانية)", 61 | "track_preview_url": "رابط معاينة الأغنية", 62 | "explicit": "صريح", 63 | "popularity": "الشعبية", 64 | "isrc": "ISRC", 65 | "is_playable": "قابل للتشغيل", 66 | "added_by": "أضيف بواسطة", 67 | "added_at": "أضيف في", 68 | "album": { 69 | "album_genres": "أنواع الألبوم", 70 | "label": "العلامة التجارية", 71 | "copyrights": "حقوق النشر" 72 | }, 73 | "artist": { 74 | "artist_genres": "أنواع الفنان" 75 | }, 76 | "audio_features": { 77 | "danceability": "قابلية الرقص", 78 | "energy": "الطاقة", 79 | "key": "المفتاح", 80 | "loudness": "مستوى الصوت", 81 | "mode": "النمط", 82 | "speechiness": "محتوى الكلام", 83 | "acousticness": "الطابع الصوتي", 84 | "instrumentalness": "الطابع الآلي", 85 | "liveness": "الحيوية", 86 | "valence": "الإيجابية", 87 | "tempo": "الإيقاع", 88 | "time_signature": "توقيع الزمن" 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/i18n/locales/de/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Exportiere deine Spotify-Playlists.", 3 | "get_started": "Loslegen", 4 | "subtitle": "{{min}}-{{max}} von {{total}} Playlists für {{userId}}", 5 | "subtitle_search": "{{total}} Ergebnisse mit \"{{query}}\" im Playlist-Namen", 6 | "subtitle_search_advanced": "{{total}} Ergebnisse für die erweiterte Suche \"{{query}}\"", 7 | "search": "Suchen", 8 | "export_all": "Alles exportieren", 9 | "exporting_done": "Fertig!", 10 | "exporting_playlist": "Exportiere {{playlistName}}...", 11 | "export_search_results": "Ergebnisse exportieren", 12 | "top_menu": { 13 | "help": "Hilfe", 14 | "toggle_dark_mode": "Dunkelmodus umschalten", 15 | "change_language": "Sprache ändern", 16 | "change_user": "Benutzer wechseln" 17 | }, 18 | "config": { 19 | "include_artists_data": "Künstlerdaten einschließen", 20 | "include_audio_features_data": "Audio-Merkmale einschließen", 21 | "include_album_data": "Albumdaten einschließen" 22 | }, 23 | "help": { 24 | "title": "Schnellreferenz", 25 | "search_syntax": { 26 | "title": "Erweiterte Suchsyntax", 27 | "query": "Suchanfrage", 28 | "behavior": "Verhalten", 29 | "public_true": "Nur öffentliche Playlists anzeigen", 30 | "public_false": "Nur private Playlists anzeigen", 31 | "collaborative_true": "Nur kollaborative Playlists anzeigen", 32 | "collaborative_false": "Keine kollaborativen Playlists anzeigen", 33 | "owner_me": "Nur Playlists anzeigen, die ich besitze", 34 | "owner_owner": "Nur Playlists anzeigen, die [owner] besitzt", 35 | "more_detail": "Für weitere Details siehe die vollständige Projektdokumentation." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Name", 40 | "owner": "Besitzer", 41 | "tracks": "Titel", 42 | "public": "Öffentlich?", 43 | "collaborative": "Kollaborativ?", 44 | "not_supported": "Diese Playlist wird nicht unterstützt", 45 | "export": "Exportieren" 46 | }, 47 | "track": { 48 | "track_uri": "Track-URI", 49 | "track_name": "Track-Name", 50 | "artist_uris": "Künstler-URI(s)", 51 | "artist_names": "Künstlername(n)", 52 | "album_uri": "Album-URI", 53 | "album_name": "Album-Name", 54 | "album_artist_uris": "Album-Künstler-URI(s)", 55 | "album_artist_names": "Album-Künstlername(n)", 56 | "album_release_date": "Veröffentlichungsdatum des Albums", 57 | "album_image_url": "Album-Bild-URL", 58 | "disc_number": "Disc-Nummer", 59 | "track_number": "Track-Nummer", 60 | "track_duration": "Track-Dauer (ms)", 61 | "track_preview_url": "Track-Vorschau-URL", 62 | "explicit": "Explizit", 63 | "popularity": "Beliebtheit", 64 | "isrc": "ISRC", 65 | "is_playable": "Ist abspielbar", 66 | "added_by": "Hinzugefügt von", 67 | "added_at": "Hinzugefügt am", 68 | "album": { 69 | "album_genres": "Album-Genres", 70 | "label": "Label", 71 | "copyrights": "Urheberrechte" 72 | }, 73 | "artist": { 74 | "artist_genres": "Künstler-Genres" 75 | }, 76 | "audio_features": { 77 | "danceability": "Tanzbarkeit", 78 | "energy": "Energie", 79 | "key": "Tonart", 80 | "loudness": "Lautstärke", 81 | "mode": "Modus", 82 | "speechiness": "Sprachanteil", 83 | "acousticness": "Akustik", 84 | "instrumentalness": "Instrumentalität", 85 | "liveness": "Lebendigkeit", 86 | "valence": "Valenz", 87 | "tempo": "Tempo", 88 | "time_signature": "Taktart" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Export your Spotify playlists.", 3 | "get_started": "Get Started", 4 | "subtitle": "{{min}}-{{max}} of {{total}} playlists for {{userId}}", 5 | "subtitle_search": "{{total}} results with \"{{query}}\" in playlist name", 6 | "subtitle_search_advanced": "{{total}} results for advanced query \"{{query}}\"", 7 | "search": "Search", 8 | "export_all": "Export All", 9 | "exporting_done": "Done!", 10 | "exporting_playlist": "Exporting {{playlistName}}...", 11 | "export_search_results": "Export Results", 12 | "top_menu": { 13 | "help": "Help", 14 | "toggle_dark_mode": "Toggle dark mode", 15 | "change_language": "Change language", 16 | "change_user": "Change user" 17 | }, 18 | "config": { 19 | "include_artists_data": "Include artists data", 20 | "include_audio_features_data": "Include audio features data", 21 | "include_album_data": "Include album data" 22 | }, 23 | "help": { 24 | "title": "Quick Reference", 25 | "search_syntax": { 26 | "title": "Advanced Search Syntax", 27 | "query": "Query", 28 | "behavior": "Behavior", 29 | "public_true": "Only show public playlists", 30 | "public_false": "Only show private playlists", 31 | "collaborative_true": "Only show collaborative playlists", 32 | "collaborative_false": "Don't show collaborative playlists", 33 | "owner_me": "Only show playlists I own", 34 | "owner_owner": "Only show playlists owned by [owner]", 35 | "more_detail": "For more detail please refer to the full project documentation." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Name", 40 | "owner": "Owner", 41 | "tracks": "Tracks", 42 | "public": "Public?", 43 | "collaborative": "Collaborative?", 44 | "not_supported": "This playlist is not supported", 45 | "export": "Export" 46 | }, 47 | "track": { 48 | "track_uri": "Track URI", 49 | "track_name": "Track Name", 50 | "artist_uris": "Artist URI(s)", 51 | "artist_names": "Artist Name(s)", 52 | "album_uri": "Album URI", 53 | "album_name": "Album Name", 54 | "album_artist_uris": "Album Artist URI(s)", 55 | "album_artist_names": "Album Artist Name(s)", 56 | "album_release_date": "Album Release Date", 57 | "album_image_url": "Album Image URL", 58 | "disc_number": "Disc Number", 59 | "track_number": "Track Number", 60 | "track_duration": "Track Duration (ms)", 61 | "track_preview_url": "Track Preview URL", 62 | "explicit": "Explicit", 63 | "popularity": "Popularity", 64 | "isrc": "ISRC", 65 | "is_playable": "Is Playable", 66 | "added_by": "Added By", 67 | "added_at": "Added At", 68 | "album": { 69 | "album_genres": "Album Genres", 70 | "label": "Label", 71 | "copyrights": "Copyrights" 72 | }, 73 | "artist": { 74 | "artist_genres": "Artist Genres" 75 | }, 76 | "audio_features": { 77 | "danceability": "Danceability", 78 | "energy": "Energy", 79 | "key": "Key", 80 | "loudness": "Loudness", 81 | "mode": "Mode", 82 | "speechiness": "Speechiness", 83 | "acousticness": "Acousticness", 84 | "instrumentalness": "Instrumentalness", 85 | "liveness": "Liveness", 86 | "valence": "Valence", 87 | "tempo": "Tempo", 88 | "time_signature": "Time Signature" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/es/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Exporta tus playlists de Spotify.", 3 | "get_started": "Comenzar", 4 | "subtitle": "{{min}}-{{max}} de {{total}} playlists para {{userId}}", 5 | "subtitle_search": "{{total}} resultados con \"{{query}}\" en el nombre de la playlist", 6 | "subtitle_search_advanced": "{{total}} resultados para la búsqueda avanzada \"{{query}}\"", 7 | "search": "Buscar", 8 | "export_all": "Exportar todo", 9 | "exporting_done": "¡Hecho!", 10 | "exporting_playlist": "Exportando {{playlistName}}...", 11 | "export_search_results": "Exportar resultados", 12 | "top_menu": { 13 | "help": "Ayuda", 14 | "toggle_dark_mode": "Activar/desactivar modo oscuro", 15 | "change_language": "Cambiar idioma", 16 | "change_user": "Cambiar usuario" 17 | }, 18 | "config": { 19 | "include_artists_data": "Incluir datos de los artistas", 20 | "include_audio_features_data": "Incluir características de audio", 21 | "include_album_data": "Incluir datos del álbum" 22 | }, 23 | "help": { 24 | "title": "Referencia rápida", 25 | "search_syntax": { 26 | "title": "Sintaxis de búsqueda avanzada", 27 | "query": "Consulta", 28 | "behavior": "Comportamiento", 29 | "public_true": "Mostrar solo playlists públicas", 30 | "public_false": "Mostrar solo playlists privadas", 31 | "collaborative_true": "Mostrar solo playlists colaborativas", 32 | "collaborative_false": "No mostrar playlists colaborativas", 33 | "owner_me": "Mostrar solo playlists que yo poseo", 34 | "owner_owner": "Mostrar solo playlists poseídas por [owner]", 35 | "more_detail": "Para más detalles, consulta la documentación completa del proyecto." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Nombre", 40 | "owner": "Propietario", 41 | "tracks": "Canciones", 42 | "public": "¿Pública?", 43 | "collaborative": "¿Colaborativa?", 44 | "not_supported": "Esta playlist no es compatible", 45 | "export": "Exportar" 46 | }, 47 | "track": { 48 | "track_uri": "URI de la canción", 49 | "track_name": "Nombre de la canción", 50 | "artist_uris": "URI(s) del artista", 51 | "artist_names": "Nombre(s) del artista", 52 | "album_uri": "URI del álbum", 53 | "album_name": "Nombre del álbum", 54 | "album_artist_uris": "URI(s) del artista del álbum", 55 | "album_artist_names": "Nombre(s) del artista del álbum", 56 | "album_release_date": "Fecha de lanzamiento del álbum", 57 | "album_image_url": "URL de la imagen del álbum", 58 | "disc_number": "Número de disco", 59 | "track_number": "Número de la canción", 60 | "track_duration": "Duración de la canción (ms)", 61 | "track_preview_url": "URL de vista previa de la canción", 62 | "explicit": "Explícito", 63 | "popularity": "Popularidad", 64 | "isrc": "ISRC", 65 | "is_playable": "Es reproducible", 66 | "added_by": "Añadido por", 67 | "added_at": "Añadido en", 68 | "album": { 69 | "album_genres": "Géneros del álbum", 70 | "label": "Sello", 71 | "copyrights": "Derechos de autor" 72 | }, 73 | "artist": { 74 | "artist_genres": "Géneros del artista" 75 | }, 76 | "audio_features": { 77 | "danceability": "Bailabilidad", 78 | "energy": "Energía", 79 | "key": "Tonalidad", 80 | "loudness": "Volumen", 81 | "mode": "Modo", 82 | "speechiness": "Hablado", 83 | "acousticness": "Acústica", 84 | "instrumentalness": "Instrumentalidad", 85 | "liveness": "Vivacidad", 86 | "valence": "Valencia", 87 | "tempo": "Tempo", 88 | "time_signature": "Compás" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/fr/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Exportez vos playlists Spotify.", 3 | "get_started": "Commencer", 4 | "subtitle": "{{min}}-{{max}} de {{total}} playlists pour {{userId}}", 5 | "subtitle_search": "{{total}} résultats avec \"{{query}}\" dans le nom de la playlist", 6 | "subtitle_search_advanced": "{{total}} résultats pour la requête avancée \"{{query}}\"", 7 | "search": "Rechercher", 8 | "export_all": "Tout exporter", 9 | "exporting_done": "Terminé!", 10 | "exporting_playlist": "Exportation de {{playlistName}}...", 11 | "export_search_results": "Exporter les résultats", 12 | "top_menu": { 13 | "help": "Aide", 14 | "toggle_dark_mode": "Activer/désactiver le mode sombre", 15 | "change_language": "Changer de langue", 16 | "change_user": "Changer d'utilisateur" 17 | }, 18 | "config": { 19 | "include_artists_data": "Inclure les données des artistes", 20 | "include_audio_features_data": "Inclure les caractéristiques audio", 21 | "include_album_data": "Inclure les données de l'album" 22 | }, 23 | "help": { 24 | "title": "Référence rapide", 25 | "search_syntax": { 26 | "title": "Syntaxe de recherche avancée", 27 | "query": "Requête", 28 | "behavior": "Comportement", 29 | "public_true": "Afficher uniquement les playlists publiques", 30 | "public_false": "Afficher uniquement les playlists privées", 31 | "collaborative_true": "Afficher uniquement les playlists collaboratives", 32 | "collaborative_false": "Ne pas afficher les playlists collaboratives", 33 | "owner_me": "Afficher uniquement mes playlists", 34 | "owner_owner": "Afficher uniquement les playlists appartenant à [owner]", 35 | "more_detail": "Pour plus de détails, veuillez consulter la documentation complète du projet." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Nom", 40 | "owner": "Propriétaire", 41 | "tracks": "Titres", 42 | "public": "Public?", 43 | "collaborative": "Collaboratif?", 44 | "not_supported": "Cette playlist n'est pas prise en charge", 45 | "export": "Exporter" 46 | }, 47 | "track": { 48 | "track_uri": "URI du titre", 49 | "track_name": "Nom du titre", 50 | "artist_uris": "URI(s) de l'artiste", 51 | "artist_names": "Nom(s) de l'artiste", 52 | "album_uri": "URI de l'album", 53 | "album_name": "Nom de l'album", 54 | "album_artist_uris": "URI(s) de l'artiste de l'album", 55 | "album_artist_names": "Nom(s) de l'artiste de l'album", 56 | "album_release_date": "Date de sortie de l'album", 57 | "album_image_url": "URL de l'image de l'album", 58 | "disc_number": "Numéro de disque", 59 | "track_number": "Numéro du titre", 60 | "track_duration": "Durée du titre (ms)", 61 | "track_preview_url": "URL de prévisualisation du titre", 62 | "explicit": "Explicite", 63 | "popularity": "Popularité", 64 | "isrc": "ISRC", 65 | "is_playable": "Est jouable", 66 | "added_by": "Ajouté par", 67 | "added_at": "Ajouté le", 68 | "album": { 69 | "album_genres": "Genres de l'album", 70 | "label": "Label", 71 | "copyrights": "Droits d'auteur" 72 | }, 73 | "artist": { 74 | "artist_genres": "Genres de l'artiste" 75 | }, 76 | "audio_features": { 77 | "danceability": "Danseabilité", 78 | "energy": "Énergie", 79 | "key": "Tonalité", 80 | "loudness": "Volume sonore", 81 | "mode": "Mode", 82 | "speechiness": "Parlabilité", 83 | "acousticness": "Acoustique", 84 | "instrumentalness": "Instrumental", 85 | "liveness": "Vivacité", 86 | "valence": "Valence", 87 | "tempo": "Tempo", 88 | "time_signature": "Mesure" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/it/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Esporta le tue playlist di Spotify.", 3 | "get_started": "Inizia", 4 | "subtitle": "{{min}}-{{max}} di {{total}} playlist per {{userId}}", 5 | "subtitle_search": "{{total}} risultati con \"{{query}}\" nel nome della playlist", 6 | "subtitle_search_advanced": "{{total}} risultati per la ricerca avanzata \"{{query}}\"", 7 | "search": "Cerca", 8 | "export_all": "Esporta tutto", 9 | "exporting_done": "Fatto!", 10 | "exporting_playlist": "Esportando {{playlistName}}...", 11 | "export_search_results": "Esporta i risultati", 12 | "top_menu": { 13 | "help": "Aiuto", 14 | "toggle_dark_mode": "Attiva/disattiva modalità scura", 15 | "change_language": "Cambia lingua", 16 | "change_user": "Cambia utente" 17 | }, 18 | "config": { 19 | "include_artists_data": "Includi dati degli artisti", 20 | "include_audio_features_data": "Includi caratteristiche audio", 21 | "include_album_data": "Includi dati dell'album" 22 | }, 23 | "help": { 24 | "title": "Riferimento rapido", 25 | "search_syntax": { 26 | "title": "Sintassi di ricerca avanzata", 27 | "query": "Query", 28 | "behavior": "Comportamento", 29 | "public_true": "Mostra solo playlist pubbliche", 30 | "public_false": "Mostra solo playlist private", 31 | "collaborative_true": "Mostra solo playlist collaborative", 32 | "collaborative_false": "Non mostrare playlist collaborative", 33 | "owner_me": "Mostra solo playlist che possiedo", 34 | "owner_owner": "Mostra solo playlist possedute da [owner]", 35 | "more_detail": "Per ulteriori dettagli, consultare la documentazione completa del progetto." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Nome", 40 | "owner": "Proprietario", 41 | "tracks": "Tracce", 42 | "public": "Pubblica?", 43 | "collaborative": "Collaborativa?", 44 | "not_supported": "Questa playlist non è supportata", 45 | "export": "Esporta" 46 | }, 47 | "track": { 48 | "track_uri": "URI della traccia", 49 | "track_name": "Nome della traccia", 50 | "artist_uris": "URI dell'artista", 51 | "artist_names": "Nome dell'artista", 52 | "album_uri": "URI dell'album", 53 | "album_name": "Nome dell'album", 54 | "album_artist_uris": "URI dell'artista dell'album", 55 | "album_artist_names": "Nome dell'artista dell'album", 56 | "album_release_date": "Data di rilascio dell'album", 57 | "album_image_url": "URL dell'immagine dell'album", 58 | "disc_number": "Numero del disco", 59 | "track_number": "Numero della traccia", 60 | "track_duration": "Durata della traccia (ms)", 61 | "track_preview_url": "URL di anteprima della traccia", 62 | "explicit": "Esplicito", 63 | "popularity": "Popolarità", 64 | "isrc": "ISRC", 65 | "is_playable": "È riproducibile", 66 | "added_by": "Aggiunto da", 67 | "added_at": "Aggiunto il", 68 | "album": { 69 | "album_genres": "Generi dell'album", 70 | "label": "Etichetta", 71 | "copyrights": "Copyright" 72 | }, 73 | "artist": { 74 | "artist_genres": "Generi dell'artista" 75 | }, 76 | "audio_features": { 77 | "danceability": "Ballabilità", 78 | "energy": "Energia", 79 | "key": "Chiave", 80 | "loudness": "Volume", 81 | "mode": "Modalità", 82 | "speechiness": "Linguaggio parlato", 83 | "acousticness": "Acustica", 84 | "instrumentalness": "Strumentalità", 85 | "liveness": "Vivacità", 86 | "valence": "Valenza", 87 | "tempo": "Tempo", 88 | "time_signature": "Battuta" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/ja/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Spotifyのプレイリストをエクスポートします。", 3 | "get_started": "開始する", 4 | "subtitle": "{{min}}-{{max}} / {{total}} プレイリスト (ユーザー: {{userId}})", 5 | "subtitle_search": "プレイリスト名に \"{{query}}\" を含む結果: {{total}} 件", 6 | "subtitle_search_advanced": "高度なクエリ \"{{query}}\" の結果: {{total}} 件", 7 | "search": "検索", 8 | "export_all": "すべてエクスポート", 9 | "exporting_done": "完了!", 10 | "exporting_playlist": "プレイリスト {{playlistName}} をエクスポート中...", 11 | "export_search_results": "検索結果をエクスポート", 12 | "top_menu": { 13 | "help": "ヘルプ", 14 | "toggle_dark_mode": "ダークモード切り替え", 15 | "change_language": "言語を変更", 16 | "change_user": "ユーザーを変更" 17 | }, 18 | "config": { 19 | "include_artists_data": "アーティストデータを含める", 20 | "include_audio_features_data": "オーディオ機能データを含める", 21 | "include_album_data": "アルバムデータを含める" 22 | }, 23 | "help": { 24 | "title": "クイックリファレンス", 25 | "search_syntax": { 26 | "title": "高度な検索構文", 27 | "query": "クエリ", 28 | "behavior": "動作", 29 | "public_true": "公開プレイリストのみを表示", 30 | "public_false": "非公開プレイリストのみを表示", 31 | "collaborative_true": "共同作成プレイリストのみを表示", 32 | "collaborative_false": "共同作成プレイリストを表示しない", 33 | "owner_me": "自分が所有するプレイリストのみを表示", 34 | "owner_owner": "[owner] が所有するプレイリストのみを表示", 35 | "more_detail": "詳細は完全なプロジェクトドキュメントをご覧ください。" 36 | } 37 | }, 38 | "playlist": { 39 | "name": "名前", 40 | "owner": "所有者", 41 | "tracks": "トラック数", 42 | "public": "公開?", 43 | "collaborative": "共同作成?", 44 | "not_supported": "このプレイリストはサポートされていません", 45 | "export": "エクスポート" 46 | }, 47 | "track": { 48 | "track_uri": "トラックURI", 49 | "track_name": "トラック名", 50 | "artist_uris": "アーティストURI", 51 | "artist_names": "アーティスト名", 52 | "album_uri": "アルバムURI", 53 | "album_name": "アルバム名", 54 | "album_artist_uris": "アルバムアーティストURI", 55 | "album_artist_names": "アルバムアーティスト名", 56 | "album_release_date": "アルバム発売日", 57 | "album_image_url": "アルバム画像URL", 58 | "disc_number": "ディスク番号", 59 | "track_number": "トラック番号", 60 | "track_duration": "トラックの長さ(ミリ秒)", 61 | "track_preview_url": "トラックプレビューURL", 62 | "explicit": "明示的な内容", 63 | "popularity": "人気度", 64 | "isrc": "ISRC", 65 | "is_playable": "再生可能", 66 | "added_by": "追加者", 67 | "added_at": "追加日時", 68 | "album": { 69 | "album_genres": "アルバムジャンル", 70 | "label": "レーベル", 71 | "copyrights": "著作権" 72 | }, 73 | "artist": { 74 | "artist_genres": "アーティストジャンル" 75 | }, 76 | "audio_features": { 77 | "danceability": "ダンサビリティ", 78 | "energy": "エネルギー", 79 | "key": "キー", 80 | "loudness": "ラウドネス", 81 | "mode": "モード", 82 | "speechiness": "スピーチ性", 83 | "acousticness": "アコースティック性", 84 | "instrumentalness": "インストゥルメンタル性", 85 | "liveness": "ライブ感", 86 | "valence": "感情値", 87 | "tempo": "テンポ", 88 | "time_signature": "拍子" 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/i18n/locales/nl/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Exporteer je Spotify-afspeellijsten.", 3 | "get_started": "Beginnen", 4 | "subtitle": "{{min}}-{{max}} van {{total}} afspeellijsten voor {{userId}}", 5 | "subtitle_search": "{{total}} resultaten met \"{{query}}\" in afspeellijstnaam", 6 | "subtitle_search_advanced": "{{total}} resultaten voor geavanceerde zoekopdracht \"{{query}}\"", 7 | "search": "Zoeken", 8 | "export_all": "Alles exporteren", 9 | "exporting_done": "Klaar!", 10 | "exporting_playlist": "Exporteer {{playlistName}}...", 11 | "export_search_results": "Resultaten exporteren", 12 | "top_menu": { 13 | "help": "Help", 14 | "toggle_dark_mode": "Donkere modus wisselen", 15 | "change_language": "Taal wijzigen", 16 | "change_user": "Gebruiker wijzigen" 17 | }, 18 | "config": { 19 | "include_artists_data": "Inclusief artiestgegevens", 20 | "include_audio_features_data": "Inclusief audio-eigenschappen", 21 | "include_album_data": "Inclusief albumgegevens" 22 | }, 23 | "help": { 24 | "title": "Snelle referentie", 25 | "search_syntax": { 26 | "title": "Geavanceerde zoeksyntaxis", 27 | "query": "Zoekopdracht", 28 | "behavior": "Gedrag", 29 | "public_true": "Toon alleen openbare afspeellijsten", 30 | "public_false": "Toon alleen privé afspeellijsten", 31 | "collaborative_true": "Toon alleen collaboratieve afspeellijsten", 32 | "collaborative_false": "Geen collaboratieve afspeellijsten tonen", 33 | "owner_me": "Toon alleen afspeellijsten die ik bezit", 34 | "owner_owner": "Toon alleen afspeellijsten van [owner]", 35 | "more_detail": "Voor meer details zie de volledige projectdocumentatie." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Naam", 40 | "owner": "Eigenaar", 41 | "tracks": "Nummers", 42 | "public": "Openbaar?", 43 | "collaborative": "Collaboratief?", 44 | "not_supported": "Deze afspeellijst wordt niet ondersteund", 45 | "export": "Exporteren" 46 | }, 47 | "track": { 48 | "track_uri": "Nummer URI", 49 | "track_name": "Nummmernaam", 50 | "artist_uris": "Artiest-URI", 51 | "artist_names": "Naam van artiest", 52 | "album_uri": "Album-URI", 53 | "album_name": "Naam van album", 54 | "album_artist_uris": "Artiest-URI van het album", 55 | "album_artist_names": "Naam van artiest op het album", 56 | "album_release_date": "Releasedatum van het album", 57 | "album_image_url": "Album afbeelding-URL", 58 | "disc_number": "Schijfnummer", 59 | "track_number": "Nummmernummer", 60 | "track_duration": "Nummmerduur (ms)", 61 | "track_preview_url": "Voorbeeld-URL", 62 | "explicit": "Expliciet", 63 | "popularity": "Populariteit", 64 | "isrc": "ISRC", 65 | "is_playable": "Speelbaar", 66 | "added_by": "Toegevoegd door", 67 | "added_at": "Toegevoegd op", 68 | "album": { 69 | "album_genres": "Genres van het album", 70 | "label": "Label", 71 | "copyrights": "Auteursrechten" 72 | }, 73 | "artist": { 74 | "artist_genres": "Genres van de artiest" 75 | }, 76 | "audio_features": { 77 | "danceability": "Dansbaarheid", 78 | "energy": "Energie", 79 | "key": "Toonsoort", 80 | "loudness": "Luidheid", 81 | "mode": "Modus", 82 | "speechiness": "Spraakzaamheid", 83 | "acousticness": "Akoestiek", 84 | "instrumentalness": "Instrumentaliteit", 85 | "liveness": "Levendigheid", 86 | "valence": "Valentie", 87 | "tempo": "Tempo", 88 | "time_signature": "Maatstreep" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/pt/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Exporte suas playlists do Spotify.", 3 | "get_started": "Começar", 4 | "subtitle": "{{min}}-{{max}} de {{total}} playlists para {{userId}}", 5 | "subtitle_search": "{{total}} resultados com \"{{query}}\" no nome da playlist", 6 | "subtitle_search_advanced": "{{total}} resultados para a busca avançada \"{{query}}\"", 7 | "search": "Buscar", 8 | "export_all": "Exportar tudo", 9 | "exporting_done": "Concluído!", 10 | "exporting_playlist": "Exportando {{playlistName}}...", 11 | "export_search_results": "Exportar resultados", 12 | "top_menu": { 13 | "help": "Ajuda", 14 | "toggle_dark_mode": "Ativar/desativar modo escuro", 15 | "change_language": "Mudar idioma", 16 | "change_user": "Mudar usuário" 17 | }, 18 | "config": { 19 | "include_artists_data": "Incluir dados dos artistas", 20 | "include_audio_features_data": "Incluir características de áudio", 21 | "include_album_data": "Incluir dados do álbum" 22 | }, 23 | "help": { 24 | "title": "Referência rápida", 25 | "search_syntax": { 26 | "title": "Sintaxe de busca avançada", 27 | "query": "Consulta", 28 | "behavior": "Comportamento", 29 | "public_true": "Mostrar apenas playlists públicas", 30 | "public_false": "Mostrar apenas playlists privadas", 31 | "collaborative_true": "Mostrar apenas playlists colaborativas", 32 | "collaborative_false": "Não mostrar playlists colaborativas", 33 | "owner_me": "Mostrar apenas playlists que possuo", 34 | "owner_owner": "Mostrar apenas playlists de propriedade de [owner]", 35 | "more_detail": "Para mais detalhes, consulte a documentação completa do projeto." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Nome", 40 | "owner": "Proprietário", 41 | "tracks": "Faixas", 42 | "public": "Pública?", 43 | "collaborative": "Colaborativa?", 44 | "not_supported": "Esta playlist não é suportada", 45 | "export": "Exportar" 46 | }, 47 | "track": { 48 | "track_uri": "URI da faixa", 49 | "track_name": "Nome da faixa", 50 | "artist_uris": "URI(s) do artista", 51 | "artist_names": "Nome(s) do artista", 52 | "album_uri": "URI do álbum", 53 | "album_name": "Nome do álbum", 54 | "album_artist_uris": "URI(s) do artista do álbum", 55 | "album_artist_names": "Nome(s) do artista do álbum", 56 | "album_release_date": "Data de lançamento do álbum", 57 | "album_image_url": "URL da imagem do álbum", 58 | "disc_number": "Número do disco", 59 | "track_number": "Número da faixa", 60 | "track_duration": "Duração da faixa (ms)", 61 | "track_preview_url": "URL de prévia da faixa", 62 | "explicit": "Explícita", 63 | "popularity": "Popularidade", 64 | "isrc": "ISRC", 65 | "is_playable": "É reproduzível", 66 | "added_by": "Adicionado por", 67 | "added_at": "Adicionado em", 68 | "album": { 69 | "album_genres": "Gêneros do álbum", 70 | "label": "Gravadora", 71 | "copyrights": "Direitos autorais" 72 | }, 73 | "artist": { 74 | "artist_genres": "Gêneros do artista" 75 | }, 76 | "audio_features": { 77 | "danceability": "Dançabilidade", 78 | "energy": "Energia", 79 | "key": "Tonalidade", 80 | "loudness": "Volume", 81 | "mode": "Modo", 82 | "speechiness": "Oralidade", 83 | "acousticness": "Acústica", 84 | "instrumentalness": "Instrumentalidade", 85 | "liveness": "Vivacidade", 86 | "valence": "Valência", 87 | "tempo": "Tempo", 88 | "time_signature": "Compasso" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/sv/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Exportera dina Spotify spellistor.", 3 | "get_started": "Kom igång", 4 | "subtitle": "{{min}}-{{max}} av {{total}} spellistor för {{userId}}", 5 | "subtitle_search": "{{total}} resultat med \"{{query}}\" i spellistans namn", 6 | "subtitle_search_advanced": "{{total}} resultat för avancerad sökning \"{{query}}\"", 7 | "search": "Sök", 8 | "export_all": "Exportera allt", 9 | "exporting_done": "Klart!", 10 | "exporting_playlist": "Exporterar {{playlistName}}...", 11 | "export_search_results": "Exportera resultat", 12 | "top_menu": { 13 | "help": "Hjälp", 14 | "toggle_dark_mode": "Växla mörkt läge", 15 | "change_language": "Byt språk", 16 | "change_user": "Byt användare" 17 | }, 18 | "config": { 19 | "include_artists_data": "Inkludera artistdata", 20 | "include_audio_features_data": "Inkludera ljudegenskaper", 21 | "include_album_data": "Inkludera albumdata" 22 | }, 23 | "help": { 24 | "title": "Snabbguide", 25 | "search_syntax": { 26 | "title": "Avancerad söksyntax", 27 | "query": "Fråga", 28 | "behavior": "Beteende", 29 | "public_true": "Visa endast offentliga spellistor", 30 | "public_false": "Visa endast privata spellistor", 31 | "collaborative_true": "Visa endast samarbetslistor", 32 | "collaborative_false": "Visa inte samarbetslistor", 33 | "owner_me": "Visa endast spellistor som jag äger", 34 | "owner_owner": "Visa endast spellistor som ägs av [owner]", 35 | "more_detail": "För mer information, se fullständig projekt-dokumentation." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "Namn", 40 | "owner": "Ägare", 41 | "tracks": "Låtar", 42 | "public": "Offentlig?", 43 | "collaborative": "Samarbetsvillig?", 44 | "not_supported": "Denna spellista stöds inte", 45 | "export": "Exportera" 46 | }, 47 | "track": { 48 | "track_uri": "Låtens URI", 49 | "track_name": "Låtens namn", 50 | "artist_uris": "Artistens URI", 51 | "artist_names": "Artistens namn", 52 | "album_uri": "Albumets URI", 53 | "album_name": "Albumets namn", 54 | "album_artist_uris": "Albumartistens URI", 55 | "album_artist_names": "Albumartistens namn", 56 | "album_release_date": "Albumets releasedatum", 57 | "album_image_url": "Albumets bild-URL", 58 | "disc_number": "Skivnummer", 59 | "track_number": "Låtnummer", 60 | "track_duration": "Låtlängd (ms)", 61 | "track_preview_url": "Förhandsgranskning URL", 62 | "explicit": "Explicit", 63 | "popularity": "Popularitet", 64 | "isrc": "ISRC", 65 | "is_playable": "Är spelbar", 66 | "added_by": "Tillagd av", 67 | "added_at": "Tillagd vid", 68 | "album": { 69 | "album_genres": "Albumets genrer", 70 | "label": "Skivbolag", 71 | "copyrights": "Upphovsrätt" 72 | }, 73 | "artist": { 74 | "artist_genres": "Artistens genrer" 75 | }, 76 | "audio_features": { 77 | "danceability": "Dansbarhet", 78 | "energy": "Energi", 79 | "key": "Tonart", 80 | "loudness": "Ljudstyrka", 81 | "mode": "Läge", 82 | "speechiness": "Talighet", 83 | "acousticness": "Akustik", 84 | "instrumentalness": "Instrumentalhet", 85 | "liveness": "Livlighet", 86 | "valence": "Valens", 87 | "tempo": "Tempo", 88 | "time_signature": "Taktart" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n/locales/tr/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Spotify çalma listelerinizi dışa aktarın.", 3 | "get_started": "Başlayın", 4 | "subtitle": "{{userId}} için toplam {{total}} oynatma listesinden {{min}}-{{max}} arası gösteriliyor", 5 | "subtitle_search": "{{total}} sonuç bulundu, çalma listesi adında \"{{query}}\" içeren listeler:", 6 | "subtitle_search_advanced": "{{total}} sonuç, gelişmiş sorgu \"{{query}}\" için", 7 | "search": "Ara", 8 | "export_all": "Tümünü Dışa Aktar", 9 | "exporting_done": "Tamamlandı!", 10 | "exporting_playlist": "{{playlistName}} çalma listesi dışa aktarılıyor...", 11 | "export_search_results": "Sonuçları Dışa Aktar", 12 | "top_menu": { 13 | "help": "Yardım", 14 | "toggle_dark_mode": "Karanlık mod aç/kapa", 15 | "change_language": "Dili Değiştir", 16 | "change_user": "Kullanıcıyı Değiştir" 17 | }, 18 | "config": { 19 | "include_artists_data": "Sanatçı verilerini dahil et", 20 | "include_audio_features_data": "Ses özellikleri verilerini dahil et", 21 | "include_album_data": "Albüm verilerini dahil et" 22 | }, 23 | "help": { 24 | "title": "Hızlı Referans", 25 | "search_syntax": { 26 | "title": "Gelişmiş Arama Söz Dizimi", 27 | "query": "Sorgu", 28 | "behavior": "Davranış", 29 | "public_true": "Yalnızca herkese açık çalma listelerini göster", 30 | "public_false": "Yalnızca özel çalma listelerini göster", 31 | "collaborative_true": "Yalnızca ortak kullanım çalma listelerini göster", 32 | "collaborative_false": "Ortak kullanım çalma listelerini gösterme", 33 | "owner_me": "Yalnızca benim sahip olduğum çalma listelerini göster", 34 | "owner_owner": "[owner] tarafından oluşturulan çalma listelerini göster", 35 | "more_detail": "Daha fazla bilgi için lütfen proje dokümantasyonuna bakın." 36 | } 37 | }, 38 | "playlist": { 39 | "name": "İsim", 40 | "owner": "Sahibi", 41 | "tracks": "Parçalar", 42 | "public": "Herkese Açık?", 43 | "collaborative": "Ortak Liste mi?", 44 | "not_supported": "Bu çalma listesi desteklenmiyor", 45 | "export": "Dışa Aktar" 46 | }, 47 | "track": { 48 | "track_uri": "Parça URI", 49 | "track_name": "Parça Adı", 50 | "artist_uris": "Sanatçı URI", 51 | "artist_names": "Sanatçı Adı", 52 | "album_uri": "Albüm URI", 53 | "album_name": "Albüm Adı", 54 | "album_artist_uris": "Albümdeki Sanatçı URI", 55 | "album_artist_names": "Albümdeki Sanatçı Adı", 56 | "album_release_date": "Albüm Çıkış Tarihi", 57 | "album_image_url": "Albüm Resim URL'si", 58 | "disc_number": "Disk Numarası", 59 | "track_number": "Parça Numarası", 60 | "track_duration": "Parça Süresi (ms)", 61 | "track_preview_url": "Parça Önizleme URL'si", 62 | "explicit": "Açık İçerik", 63 | "popularity": "Popülerlik", 64 | "isrc": "ISRC", 65 | "is_playable": "Çalınabilir mi?", 66 | "added_by": "Ekleyen", 67 | "added_at": "Eklenme Tarihi", 68 | "album": { 69 | "album_genres": "Albüm Türleri", 70 | "label": "Plak Şirketi", 71 | "copyrights": "Telif Hakları" 72 | }, 73 | "artist": { 74 | "artist_genres": "Sanatçı Türleri" 75 | }, 76 | "audio_features": { 77 | "danceability": "Dans Edilebilirlik", 78 | "energy": "Enerji", 79 | "key": "Ton", 80 | "loudness": "Ses Seviyesi", 81 | "mode": "Mod", 82 | "speechiness": "Konuşma Oranı", 83 | "acousticness": "Akustiklik", 84 | "instrumentalness": "Enstrümantalite", 85 | "liveness": "Canlılık", 86 | "valence": "Duygusallık", 87 | "tempo": "Tempo", 88 | "time_signature": "Ölçü İşareti" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | import { library } from '@fortawesome/fontawesome-svg-core' 2 | import { fab } from '@fortawesome/free-brands-svg-icons' 3 | import { faCheckCircle, faTimesCircle, faFileArchive, faHeart } from '@fortawesome/free-regular-svg-icons' 4 | import { faBolt, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck } from '@fortawesome/free-solid-svg-icons' 5 | 6 | library.add( 7 | fab, 8 | faCheckCircle, 9 | faTimesCircle, 10 | faFileArchive, 11 | faHeart, 12 | faBolt, 13 | faMusic, 14 | faDownload, 15 | faCog, 16 | faSearch, 17 | faTimes, 18 | faSignOutAlt, 19 | faSync, 20 | faLightbulb, 21 | faCircleInfo, 22 | faGlobe, 23 | faCheck 24 | ) 25 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | // Bootstrap variables 2 | $link-color: #337ab7; 3 | $primary: #5cb85c; 4 | $table-hover-bg: rgba(#000, .025); 5 | $btn-transition: none; 6 | $body-color: #191414; 7 | $input-btn-focus-box-shadow: none; 8 | $input-placeholder-color: #dee2e6; 9 | $link-decoration: none; 10 | $link-hover-decoration: underline; 11 | $pagination-focus-box-shadow: none; 12 | $pagination-focus-bg: none; 13 | 14 | @import "~bootstrap/scss/bootstrap"; 15 | 16 | h1 a { 17 | color: var(--bs-dark); 18 | text-decoration: none; 19 | 20 | &:hover { 21 | color: var(--bs-dark); 22 | text-decoration: none; 23 | } 24 | } 25 | 26 | @include color-mode(dark) { 27 | 28 | h1 a, 29 | h1 a:hover { 30 | color: var(--bs-light); 31 | } 32 | 33 | input::placeholder { 34 | opacity: 0.3 35 | } 36 | } 37 | 38 | // Small button styles 39 | $btn-padding-x-xs: .35rem !default; 40 | $btn-padding-y-xs: .12rem !default; 41 | $input-btn-line-height-xs: 1.3 !default; 42 | 43 | .btn-primary { 44 | color: #fff !important; 45 | } 46 | 47 | .btn.btn-xs { 48 | // line-height: ensure proper height of button next to small input 49 | @include button-size($btn-padding-y-xs, $btn-padding-x-xs, $font-size-sm, $btn-border-radius-sm); 50 | } 51 | 52 | #languageDropdown { 53 | display: inline; 54 | } 55 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Bugsnag from '@bugsnag/js' 3 | import BugsnagPluginReact from '@bugsnag/plugin-react' 4 | import { createRoot } from 'react-dom/client' 5 | import "./index.scss" 6 | import App from "./App" 7 | import reportWebVitals from "./reportWebVitals" 8 | import './i18n/config' 9 | 10 | // https://caniuse.com/mdn-javascript_builtins_array_flatmap 11 | require('array.prototype.flatmap').shim() 12 | 13 | Bugsnag.start({ 14 | apiKey: 'a65916528275f084a1754a59797a36b3', 15 | plugins: [new BugsnagPluginReact()], 16 | redactedKeys: ['Authorization'], 17 | enabledReleaseStages: ['production', 'staging'], 18 | onError: function (event) { 19 | event.request.url = "[REDACTED]" // Don't send access tokens 20 | 21 | if (event.originalError.isAxiosError) { 22 | event.groupingHash = event.originalError.message 23 | } 24 | } 25 | }) 26 | 27 | const ErrorBoundary = Bugsnag.getPlugin('react')!.createErrorBoundary(React) 28 | const container = document.getElementById('root') 29 | const root = createRoot(container!) 30 | 31 | root.render( 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | 39 | // If you want to start measuring performance in your app, pass a function 40 | // to log results (for example: reportWebVitals(console.log)) 41 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 42 | reportWebVitals(); 43 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportCallback } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportCallback) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => { 6 | onCLS(onPerfEntry); 7 | onINP(onPerfEntry); 8 | onFCP(onPerfEntry); 9 | onLCP(onPerfEntry); 10 | onTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react", 22 | "baseUrl": "src" 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------