├── .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 | [](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 | You need to enable JavaScript to run this app.
15 |
16 |
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 |
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 |
28 | {this.props.i18n.t("get_started")}
29 |
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 |
30 |
31 |
32 | { /* eslint-disable-next-line */}
33 |
34 | «
35 |
36 |
37 | = this.totalPages() ? 'page-item disabled' : 'page-item'}>
38 | { /* eslint-disable-next-line */}
39 |
40 | »
41 |
42 |
43 |
44 |
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 |
78 | {/* @ts-ignore */}
79 | {this.props.i18n.t("playlist.export")}
80 |
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 |
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 |
207 |
208 |
209 |
210 |
211 |
212 | {this.props.i18n.t("playlist.name")}
213 | {this.props.i18n.t("playlist.owner")}
214 | {this.props.i18n.t("playlist.tracks")}
215 | {this.props.i18n.t("playlist.public")}
216 | {this.props.i18n.t("playlist.collaborative")}
217 |
218 |
226 |
227 |
228 |
229 |
230 | {this.state.playlists.map((playlist: any, i) => {
231 | return
237 | })}
238 |
239 |
240 |
241 |
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
86 | {text}
87 |
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 |
64 |
65 |
66 |
67 |
68 | {this.props.i18n.t("help.title")}
69 |
70 |
71 | {this.props.i18n.t("help.search_syntax.title")}
72 |
73 |
74 |
75 | {this.props.i18n.t("help.search_syntax.query")}
76 | {this.props.i18n.t("help.search_syntax.behavior")}
77 |
78 |
79 |
80 |
81 | public:true
82 | {this.props.i18n.t("help.search_syntax.public_true")}
83 |
84 |
85 | public:false
86 | {this.props.i18n.t("help.search_syntax.public_true")}
87 |
88 |
89 | collaborative:true
90 | {this.props.i18n.t("help.search_syntax.collaborative_true")}
91 |
92 |
93 | collaborative:false
94 | {this.props.i18n.t("help.search_syntax.collaborative_false")}
95 |
96 |
97 | owner:me
98 | {this.props.i18n.t("help.search_syntax.owner_me")}
99 |
100 |
101 | owner:[owner]
102 | }} />
103 |
104 |
105 |
106 |
107 | {/* eslint-disable-next-line*/}
108 | }} />
109 |
110 |
111 | >
112 | ) : ''
113 |
114 | const logoutButton = this.props.loggedIn ?
115 |
116 | : ''
117 |
118 | return (
119 |
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 |
109 |
112 |
115 |
116 |
117 |
120 |
123 | Name
124 |
125 |
128 | Owner
129 |
130 |
133 | Tracks
134 |
135 |
138 | Public?
139 |
140 |
143 | Collaborative?
144 |
145 |
148 |
152 |
162 |
166 |
167 | Export All
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
186 |
190 |
191 |
192 |
193 |
196 | Liked
197 |
198 |
199 |
200 |
203 | watsonbox
204 |
205 |
206 |
209 | 1
210 |
211 |
214 |
224 |
228 |
229 |
230 |
233 |
243 |
247 |
248 |
249 |
252 |
256 |
266 |
270 |
271 | Export
272 |
273 |
274 |
275 |
276 |
277 |
287 |
291 |
292 |
293 |
294 |
297 | Ghostpoet – Peanut Butter Blues and Melancholy Jam
298 |
299 |
300 |
301 |
304 | watsonbox
305 |
306 |
307 |
310 | 10
311 |
312 |
315 |
325 |
329 |
330 |
331 |
334 |
344 |
348 |
349 |
350 |
353 |
357 |
367 |
371 |
372 | Export
373 |
374 |
375 |
376 |
377 |
378 |
379 |
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 |
--------------------------------------------------------------------------------