├── .github ├── dependabot.yml └── workflows │ ├── briefcase.yml │ ├── main.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── assets └── youtube-music-instructions.gif ├── pyproject.toml ├── raw_headers.txt ├── requirements.txt ├── spotify2ytmusic ├── __init__.py ├── __main__.py ├── backend.py ├── cli.py ├── gui.py ├── reverse_playlist.py ├── settings.json ├── spotify_backup.py └── ytmusic_credentials.py └── tests ├── playliststest.json └── test_basics.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/workflows/briefcase.yml: -------------------------------------------------------------------------------- 1 | name: Build with Briefcase 2 | 3 | on: 4 | release: 5 | types: [released] 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | build-and-upload: 15 | runs-on: windows-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.x' 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install briefcase 29 | 30 | - name: Update Application Version 31 | if: github.event_name == 'release' && github.event.action == 'released' 32 | run: | 33 | (Get-Content pyproject.toml) -replace 'version = ".*"', 'version = "${{ github.ref_name }}"' | Set-Content pyproject.toml 34 | shell: pwsh 35 | 36 | - name: Build with Briefcase 37 | run: briefcase create windows && briefcase build windows && briefcase package windows 38 | 39 | - name: Upload Release Asset 40 | uses: softprops/action-gh-release@v2 41 | if: github.event_name == 'release' && github.event.action == 'released' 42 | with: 43 | files: dist/*.msi 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | # If I try pyinstaller I need to use "--collect-all ytmusicapi" 48 | # https://ytmusicapi.readthedocs.io/en/stable/faq.html#how-do-i-package-ytmusicapi-with-pyinstaller 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.10", "3.11", "3.12"] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install flake8 pytest 22 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 23 | if [ -f pyproject.toml ]; then pip install .; fi 24 | - name: Lint with flake8 25 | run: | 26 | # stop the build if there are Python syntax errors or undefined names 27 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 28 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 29 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 30 | # - name: Run tests 31 | # run: | 32 | # tests/runtests 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # To get started 7 | # 8 | # Go to: https://pypi.org/manage/account/publishing/ 9 | # Fill it out. 10 | # Commit this workflow and create a release. 11 | 12 | name: Upload Python Package 13 | 14 | # See: https://github.com/pypa/gh-action-pypi-publish 15 | on: 16 | release: 17 | types: [released] 18 | 19 | jobs: 20 | deploy: 21 | environment: 22 | name: pypi 23 | url: https://pypi.org/p/spotify2ytmusic 24 | runs-on: ubuntu-latest 25 | permissions: 26 | id-token: write 27 | contents: write 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Set up Python 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: '3.x' 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install build 40 | 41 | - name: Extract version from tag 42 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 43 | 44 | - name: Update version in pyproject.toml 45 | run: | 46 | sed -i "s/version = \".*\"/version = \"${RELEASE_VERSION}\"/" pyproject.toml 47 | 48 | - name: Build package 49 | run: python -m build 50 | 51 | - name: Publish package 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | with: 54 | password: ${{ secrets.PYPI_API_TOKEN }} 55 | 56 | - name: Upload Release Asset 57 | uses: softprops/action-gh-release@v2 58 | with: 59 | files: dist/* 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # UV 99 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | #uv.lock 103 | 104 | # poetry 105 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 106 | # This is especially recommended for binary packages to ensure reproducibility, and is more 107 | # commonly ignored for libraries. 108 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 109 | #poetry.lock 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | #pdm.lock 114 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 115 | # in version control. 116 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 117 | .pdm.toml 118 | .pdm-python 119 | .pdm-build/ 120 | 121 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 122 | __pypackages__/ 123 | 124 | # Celery stuff 125 | celerybeat-schedule 126 | celerybeat.pid 127 | 128 | # SageMath parsed files 129 | *.sage.py 130 | 131 | # Environments 132 | .env 133 | .venv 134 | env/ 135 | venv/ 136 | ENV/ 137 | env.bak/ 138 | venv.bak/ 139 | 140 | # Spyder project settings 141 | .spyderproject 142 | .spyproject 143 | 144 | # Rope project settings 145 | .ropeproject 146 | 147 | # mkdocs documentation 148 | /site 149 | 150 | # mypy 151 | .mypy_cache/ 152 | .dmypy.json 153 | dmypy.json 154 | 155 | # Pyre type checker 156 | .pyre/ 157 | 158 | # pytype static type analyzer 159 | .pytype/ 160 | 161 | # Cython debug symbols 162 | cython_debug/ 163 | 164 | # PyCharm 165 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 166 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 167 | # and can be added to the global gitignore or merged into this file. For a more nuclear 168 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 169 | #.idea/ 170 | 171 | # PyPI configuration file 172 | .pypirc 173 | # sensitive info 174 | oauth.json 175 | raw_headers.txt 176 | playlists.json -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | #- repo: local 3 | # hooks: 4 | # - id: pytest 5 | # name: Check pytest unit tests pass 6 | # entry: bash -c "PYTHONPATH=. pytest" 7 | # pass_filenames: false 8 | # language: system 9 | # types: [python] 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v4.5.0 # Use the ref you want to point at 12 | hooks: 13 | - id: trailing-whitespace 14 | - id: end-of-file-fixer 15 | - id: check-yaml 16 | - id: check-ast 17 | - id: check-merge-conflict 18 | - id: detect-aws-credentials 19 | - id: detect-private-key 20 | - id: requirements-txt-fixer 21 | - repo: https://github.com/psf/black 22 | rev: 23.10.1 # Use the specific version of Black you want to run 23 | hooks: 24 | - id: black 25 | language: python 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Portions licensed under MIT license (spotify-backup.py) 2 | 3 | Creative Commons Legal Code 4 | 5 | CC0 1.0 Universal 6 | 7 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 8 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 9 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 10 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 11 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 12 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 13 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 14 | HEREUNDER. 15 | 16 | Statement of Purpose 17 | 18 | The laws of most jurisdictions throughout the world automatically confer 19 | exclusive Copyright and Related Rights (defined below) upon the creator 20 | and subsequent owner(s) (each and all, an "owner") of an original work of 21 | authorship and/or a database (each, a "Work"). 22 | 23 | Certain owners wish to permanently relinquish those rights to a Work for 24 | the purpose of contributing to a commons of creative, cultural and 25 | scientific works ("Commons") that the public can reliably and without fear 26 | of later claims of infringement build upon, modify, incorporate in other 27 | works, reuse and redistribute as freely as possible in any form whatsoever 28 | and for any purposes, including without limitation commercial purposes. 29 | These owners may contribute to the Commons to promote the ideal of a free 30 | culture and the further production of creative, cultural and scientific 31 | works, or to gain reputation or greater distribution for their Work in 32 | part through the use and efforts of others. 33 | 34 | For these and/or other purposes and motivations, and without any 35 | expectation of additional consideration or compensation, the person 36 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 37 | is an owner of Copyright and Related Rights in the Work, voluntarily 38 | elects to apply CC0 to the Work and publicly distribute the Work under its 39 | terms, with knowledge of his or her Copyright and Related Rights in the 40 | Work and the meaning and intended legal effect of CC0 on those rights. 41 | 42 | 1. Copyright and Related Rights. A Work made available under CC0 may be 43 | protected by copyright and related or neighboring rights ("Copyright and 44 | Related Rights"). Copyright and Related Rights include, but are not 45 | limited to, the following: 46 | 47 | i. the right to reproduce, adapt, distribute, perform, display, 48 | communicate, and translate a Work; 49 | ii. moral rights retained by the original author(s) and/or performer(s); 50 | iii. publicity and privacy rights pertaining to a person's image or 51 | likeness depicted in a Work; 52 | iv. rights protecting against unfair competition in regards to a Work, 53 | subject to the limitations in paragraph 4(a), below; 54 | v. rights protecting the extraction, dissemination, use and reuse of data 55 | in a Work; 56 | vi. database rights (such as those arising under Directive 96/9/EC of the 57 | European Parliament and of the Council of 11 March 1996 on the legal 58 | protection of databases, and under any national implementation 59 | thereof, including any amended or successor version of such 60 | directive); and 61 | vii. other similar, equivalent or corresponding rights throughout the 62 | world based on applicable law or treaty, and any national 63 | implementations thereof. 64 | 65 | 2. Waiver. To the greatest extent permitted by, but not in contravention 66 | of, applicable law, Affirmer hereby overtly, fully, permanently, 67 | irrevocably and unconditionally waives, abandons, and surrenders all of 68 | Affirmer's Copyright and Related Rights and associated claims and causes 69 | of action, whether now known or unknown (including existing as well as 70 | future claims and causes of action), in the Work (i) in all territories 71 | worldwide, (ii) for the maximum duration provided by applicable law or 72 | treaty (including future time extensions), (iii) in any current or future 73 | medium and for any number of copies, and (iv) for any purpose whatsoever, 74 | including without limitation commercial, advertising or promotional 75 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 76 | member of the public at large and to the detriment of Affirmer's heirs and 77 | successors, fully intending that such Waiver shall not be subject to 78 | revocation, rescission, cancellation, termination, or any other legal or 79 | equitable action to disrupt the quiet enjoyment of the Work by the public 80 | as contemplated by Affirmer's express Statement of Purpose. 81 | 82 | 3. Public License Fallback. Should any part of the Waiver for any reason 83 | be judged legally invalid or ineffective under applicable law, then the 84 | Waiver shall be preserved to the maximum extent permitted taking into 85 | account Affirmer's express Statement of Purpose. In addition, to the 86 | extent the Waiver is so judged Affirmer hereby grants to each affected 87 | person a royalty-free, non transferable, non sublicensable, non exclusive, 88 | irrevocable and unconditional license to exercise Affirmer's Copyright and 89 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 90 | maximum duration provided by applicable law or treaty (including future 91 | time extensions), (iii) in any current or future medium and for any number 92 | of copies, and (iv) for any purpose whatsoever, including without 93 | limitation commercial, advertising or promotional purposes (the 94 | "License"). The License shall be deemed effective as of the date CC0 was 95 | applied by Affirmer to the Work. Should any part of the License for any 96 | reason be judged legally invalid or ineffective under applicable law, such 97 | partial invalidity or ineffectiveness shall not invalidate the remainder 98 | of the License, and in such case Affirmer hereby affirms that he or she 99 | will not (i) exercise any of his or her remaining Copyright and Related 100 | Rights in the Work or (ii) assert any associated claims and causes of 101 | action with respect to the Work, in either case contrary to Affirmer's 102 | express Statement of Purpose. 103 | 104 | 4. Limitations and Disclaimers. 105 | 106 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 107 | surrendered, licensed or otherwise affected by this document. 108 | b. Affirmer offers the Work as-is and makes no representations or 109 | warranties of any kind concerning the Work, express, implied, 110 | statutory or otherwise, including without limitation warranties of 111 | title, merchantability, fitness for a particular purpose, non 112 | infringement, or the absence of latent or other defects, accuracy, or 113 | the present or absence of errors, whether or not discoverable, all to 114 | the greatest extent permissible under applicable law. 115 | c. Affirmer disclaims responsibility for clearing rights of other persons 116 | that may apply to the Work or any use thereof, including without 117 | limitation any person's Copyright and Related Rights in the Work. 118 | Further, Affirmer disclaims responsibility for obtaining any necessary 119 | consents, permissions or other rights required for any use of the 120 | Work. 121 | d. Affirmer understands and acknowledges that Creative Commons is not a 122 | party to this document and has no duty or obligation with respect to 123 | this CC0 or use of the Work. 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Overview 2 | 3 | This is a set of scripts for copying "liked" songs and playlists from Spotify to YTMusic. It provides a GUI (implemented by Yoween, formerly called spotify_to_ytmusic_gui). 4 | 5 | --- 6 | 7 | ### Preparation/Pre-Conditions 8 | 9 | 1. **Install Python and Git** (you may already have them installed). 10 | 2. **Uninstall the pip package from the original repository** (if you previously installed `linsomniac/spotify_to_ytmusic`): 11 | 12 | On Windows: 13 | 14 | ```bash 15 | python -m pip uninstall spotify2ytmusic 16 | ``` 17 | 18 | On Linux or Mac: 19 | 20 | ```bash 21 | python3 -m pip uninstall spotify2ytmusic 22 | ``` 23 | 24 | --- 25 | 26 | ### Setup Instructions 27 | 28 | #### 1. Clone, Create a Virtual Environment, and Install Required Packages 29 | 30 | Start by creating and activating a Python virtual environment to isolate dependencies. 31 | 32 | ```bash 33 | git clone https://github.com/linsomniac/spotify_to_ytmusic.git 34 | cd spotify_to_ytmusic 35 | ``` 36 | 37 | On Windows: 38 | 39 | ```bash 40 | python -m venv .venv 41 | .venv\Scripts\activate 42 | pip install ytmusicapi tk 43 | ``` 44 | 45 | On Linux or Mac: 46 | 47 | ```bash 48 | python3 -m venv .venv 49 | source .venv/bin/activate 50 | pip install ytmusicapi tk 51 | ``` 52 | 53 | --- 54 | 55 | #### 2. Generate YouTube Music Credentials 56 | 57 | To use the YouTube Music API, you need to generate valid credentials. Follow these steps: 58 | 59 | 1. **Log in to YouTube Music**: Open YouTube Music in Firefox and ensure you are logged in. 60 | 2. **Open the Inspection Tool**: Press `F12` or right-click and select _Inspect_ to open the browser's inspection tool. 61 | 3. **Access the Network Tab**: Navigate to the Network tab and filter by `/browse`. 62 | 4. **Select a Request**: Click one of the requests under the filtered results and locate the _Request Headers_ section. 63 | 5. **Toggle RAW View**: Click the RAW toggle button to view the headers in raw format. 64 | 6. **Copy Headers**: Right-click, choose _Select All_, and copy the content. 65 | 7. **Paste into `raw_headers.txt`**: Open the `raw_headers.txt` file located in the main directory of this project and paste the copied content into it. 66 | 67 | **Run the Script**: 68 | 69 | Execute the following command to generate the credentials file: 70 | 71 | On Windows: 72 | 73 | ```bash 74 | python spotify2ytmusic/ytmusic_credentials.py 75 | ``` 76 | 77 | On Linux or Mac: 78 | 79 | ```bash 80 | python3 spotify2ytmusic/ytmusic_credentials.py 81 | ``` 82 | 83 | **Important**: After running this script, the authentication file will be created. 84 | When you launch the GUI in the next step, it will automatically detect this file and log in to YouTube Music without requiring manual input. You’ll see a log message confirming this: 85 | 86 | ``` 87 | File detected, auto login 88 | ``` 89 | 90 | The GUI will **ignore the 'Login to YT Music' tab** and jump straight to the 'Spotify Backup' tab. 91 | 92 | --- 93 | 94 | #### 3. Use the GUI for Migration 95 | 96 | Now you can use the graphical user interface (GUI) to migrate your playlists and liked songs to YouTube Music. Start the GUI with the following command: 97 | 98 | On Windows: 99 | 100 | ```bash 101 | python -m spotify2ytmusic gui 102 | ``` 103 | 104 | On Linux or Mac: 105 | 106 | ```bash 107 | python3 -m spotify2ytmusic gui 108 | ``` 109 | 110 | --- 111 | 112 | ### GUI Features 113 | 114 | Once the GUI is running, you can: 115 | 116 | - **Backup Your Spotify Playlists**: Save your playlists and liked songs into the file `playlists.json`. 117 | - **Load Liked Songs**: Migrate your Spotify liked songs to YouTube Music. 118 | - **List Playlists**: View your playlists and their details. 119 | - **Copy All Playlists**: Migrate all Spotify playlists to YouTube Music. 120 | - **Copy a Specific Playlist**: Select and migrate a specific Spotify playlist to YouTube Music. 121 | 122 | --- 123 | 124 | ### Import Your Liked Songs - Tab 3 125 | 126 | #### Click the `import` button, and wait until it finished and switched to the next tab 127 | 128 | It will go through your Spotify liked songs, and like them on YTMusic. It will display 129 | the song from Spotify and then the song that it found on YTMusic that it is liking. I've 130 | spot-checked my songs and it seems to be doing a good job of matching YTMusic songs with 131 | Spotify. So far I haven't seen a single failure across a couple hundred songs, but more 132 | esoteric titles it may have issues with. 133 | 134 | ### List Your Playlists - Tab 4 135 | 136 | #### Click the `list` button, and wait until it finished and switched to the next tab 137 | 138 | This will list the playlists you have on both Spotify and YTMusic, so you can individually copy them. 139 | 140 | ### Copy Your Playlists - Tab 5 141 | 142 | You can either copy **all** playlists, or do a more surgical copy of individual playlists. 143 | Copying all playlists will use the name of the Spotify playlist as the destination playlist name on YTMusic. 144 | 145 | #### To copy all the playlists click the `copy` button, and wait until it finished and switched to the next tab 146 | 147 | **NOTE**: This does not copy the Liked playlist (see above to do that). 148 | 149 | ### Copy specific Playlist - Tab 6 150 | 151 | In the list output, find the "playlist id" (the first column) of the Spotify playlist and of the YTMusic playlist. 152 | 153 | #### Then fill both input fields and click the `copy` button 154 | 155 | The copy playlist will take the name of the YTMusic playlist and will create the 156 | playlist if it does not exist, if you start the YTMusic playlist with a "+": 157 | 158 | Re-running "copy_playlist" or "load_liked" in the event that it fails should be safe, it 159 | will not duplicate entries on the playlist. 160 | 161 | ## Command Line Usage 162 | 163 | ### Ways to Run 164 | 165 | **NOTE**: There are two possible ways to run these commands, one is via standalone commands 166 | if the application was installed, which takes the form of: `s2yt_load_liked` 167 | 168 | If not fully installed, you can replace the "s2yt\_" with "python -m spotify2ytmusic", for 169 | example: `s2yt_load_liked` becomes `python -j spotify2ytmusic load_liked` 170 | 171 | ### Login to YTMusic 172 | 173 | See "Generate YouTube Music Credentials" above. 174 | 175 | ### Backup Your Spotify Playlists 176 | 177 | Run `spotify2ytmusic/spotify_backup.py` and it will help you authorize access to your spotify account. 178 | 179 | Run: `python3 spotify_backup.py playlists.json --dump=liked,playlists --format=json` 180 | 181 | This will save your playlists and liked songs into the file "playlists.json". 182 | 183 | ### Import Your Liked Songs 184 | 185 | Run: `s2yt_load_liked` 186 | 187 | It will go through your Spotify liked songs, and like them on YTMusic. It will display 188 | the song from spotify and then the song that it found on YTMusic that it is liking. I've 189 | spot-checked my songs and it seems to be doing a good job of matching YTMusic songs with 190 | Spotify. So far I haven't seen a single failure across a couple thousand songs, but more 191 | esoteric titles it may have issues with. 192 | 193 | ### Import Your Liked Albums 194 | 195 | Run: `s2yt_load_liked_albums` 196 | 197 | Spotify stores liked albums outside of the "Liked Songs" playlist. This is the command to 198 | load your liked albums into YTMusic liked songs. 199 | 200 | ### List Your Playlists 201 | 202 | Run `s2yt_list_playlists` 203 | 204 | This will list the playlists you have on both Spotify and YTMusic. You will need to 205 | individually copy them. 206 | 207 | ### Copy Your Playlists 208 | 209 | You can either copy **all** playlists, or do a more surgical copy of individual playlists. 210 | Copying all playlists will use the name of the Spotify playlist as the destination 211 | playlist name on YTMusic. To copy all playlists, run: 212 | 213 | `s2yt_copy_all_playlists` 214 | 215 | **NOTE**: This does not copy the Liked playlist (see above to do that). 216 | 217 | In the list output above, find the "playlist id" (the first column) of the Spotify playlist, 218 | and of the YTMusic playlist, and then run: 219 | 220 | `s2yt_copy_playlist <SPOTIFY_PLAYLIST_ID> <YTMUSIC_PLAYLIST_ID>` 221 | 222 | If you need to create a playlist, you can run: 223 | 224 | `s2yt_create_playlist "<PLAYLIST_NAME>"` 225 | 226 | _Or_ the copy playlist can take the name of the YTMusic playlist and will create the 227 | playlist if it does not exist, if you start the YTMusic playlist with a "+": 228 | 229 | `s2yt_copy_playlist <SPOTIFY_PLAYLIST_ID> +<YTMUSIC_PLAYLIST_NAME>` 230 | 231 | For example: 232 | 233 | `s2yt_copy_playlist SPOTIFY_PLAYLIST_ID "+Feeling Like a PUNK"` 234 | 235 | Re-running "copy_playlist" or "load_liked" in the event that it fails should be safe, it 236 | will not duplicate entries on the playlist. 237 | 238 | ### Searching for YTMusic Tracks 239 | 240 | This is mostly for debugging, but there is a command to search for tracks in YTMusic: 241 | 242 | ## `s2yt_search --artist <ARTIST> --album <ALBUM> <TRACK_NAME>` 243 | 244 | ## Details About Search Algorithms 245 | 246 | The function first searches for albums by the given artist name on YTMusic. 247 | 248 | It then iterates over the first three album results and tries to find a track with 249 | the exact same name as the given track name. If it finds a match, it returns the 250 | track information. 251 | 252 | If the function can't find the track in the albums, it then searches for songs by the 253 | given track name and artist name. 254 | 255 | Depending on the yt_search_algo parameter, it performs one of the following actions: 256 | 257 | If yt_search_algo is 0, it simply returns the first song result. 258 | 259 | If yt_search_algo is 1, it iterates over the song results and returns the first song 260 | that matches the track name, artist name, and album name exactly. If it can't find a 261 | match, it raises a ValueError. 262 | 263 | If yt_search_algo is 2, it performs a fuzzy match. It removes everything in brackets 264 | in the song title and checks for a match with the track name, artist name, and album 265 | name. If it can't find a match, it then searches for videos with the track name and 266 | artist name. If it still can't find a match, it raises a ValueError. 267 | 268 | If the function can't find the track using any of the above methods, it raises a 269 | ValueError. 270 | 271 | ## FAQ 272 | 273 | - My copy is failing after 20-40 minutes. Is my session timing out? 274 | 275 | Try playing music in the browser on Youtube Music while you are loading the playlists, 276 | this has been reported to keep the session from timing out. 277 | 278 | - Does this run on mobile? 279 | 280 | No, this runs on Linux/Windows/MacOS. 281 | 282 | - How does the lookup algorithm work? 283 | 284 | Given the Spotify track information, it does a lookup for the album by the same artist 285 | on YTMusic, then looks at the first 3 hits looking for a track with exactly the same 286 | name. In the event that it can't find that exact track, it then does a search of songs 287 | for the track name by the same artist and simply returns the first hit. 288 | 289 | The idea is that finding the album and artist and then looking for the exact track match 290 | will be more likely to be accurate than searching for the song and artist and relying on 291 | the YTMusic algorithm to figure things out, especially for short tracks that might be 292 | have many contradictory hits like "Survival by Yes". 293 | 294 | - My copy is failing with repeated "ERROR: (Retrying) Server returned HTTP 400: Bad 295 | Request". 296 | 297 | Try running with "--track-sleep=3" argument to do a 3 second sleep between tracks. This 298 | will take much longer, but may succeed where faster rates have failed. 299 | 300 | ## License 301 | 302 | Creative Commons Zero v1.0 Universal 303 | 304 | spotify-backup.py licensed under MIT License. 305 | See <https://github.com/caseychu/spotify-backup> for more information. 306 | 307 | [//]: # " vim: set tw=90 ts=4 sw=4 ai: " 308 | -------------------------------------------------------------------------------- /assets/youtube-music-instructions.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linsomniac/spotify_to_ytmusic/d6226553128b29cdf98a1f1fdfceb5fd86511d84/assets/youtube-music-instructions.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "spotify2ytmusic" 3 | version = "0.1.0" 4 | description = "Copy Spotify playlists to YTMusic/YouTube Music" 5 | authors = ["Sean Reifschneider <jafo00@gmail.com>"] 6 | license = "Creative Commons Zero v1.0 Universal" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | ytmusicapi = "*" 12 | 13 | [build-system] 14 | requires = ["poetry-core"] 15 | build-backend = "poetry.core.masonry.api" 16 | 17 | [tool.poetry.scripts] 18 | s2yt_gui = "spotify2ytmusic.gui:main" 19 | s2yt_load_liked = "spotify2ytmusic.cli:load_liked" 20 | s2yt_load_liked_albums = "spotify2ytmusic.cli:load_liked_albums" 21 | s2yt_copy_playlist = "spotify2ytmusic.cli:copy_playlist" 22 | s2yt_copy_all_playlists = "spotify2ytmusic.cli:copy_all_playlists" 23 | s2yt_create_playlist = "spotify2ytmusic.cli:create_playlist" 24 | s2yt_list_playlists = "spotify2ytmusic.cli:list_playlists" 25 | s2yt_search = "spotify2ytmusic.cli:search" 26 | s2yt_list_liked_albums = "spotify2ytmusic.cli:list_liked_albums" 27 | s2yt_ytoauth = "spotify2ytmusic.cli:ytoauth" 28 | 29 | [tool.briefcase] 30 | project_name = "Spotify2YTMusic" 31 | bundle = "com.linsomniac" 32 | version = "0.1" 33 | license = "CC0-1.0" 34 | 35 | [tool.briefcase.app.spotify2ytmusic] 36 | formal_name = "Spotify2YTMusic" 37 | description = "A tool for helping to convert Spotify playlists to YouTube Music" 38 | sources = ['spotify2ytmusic'] 39 | requires = [ 40 | 'ytmusicapi', 41 | 'tk', 42 | ] 43 | -------------------------------------------------------------------------------- /raw_headers.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linsomniac/spotify_to_ytmusic/d6226553128b29cdf98a1f1fdfceb5fd86511d84/raw_headers.txt -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linsomniac/spotify_to_ytmusic/d6226553128b29cdf98a1f1fdfceb5fd86511d84/requirements.txt -------------------------------------------------------------------------------- /spotify2ytmusic/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from . import cli 4 | -------------------------------------------------------------------------------- /spotify2ytmusic/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from . import cli 4 | import sys 5 | import inspect 6 | 7 | def list_commands(module): 8 | # include only functions defined in e.g. 'cli' module 9 | commands = [name for name, obj in inspect.getmembers(module) if inspect.isfunction(obj)] 10 | return commands 11 | 12 | available_commands = list_commands(cli) 13 | 14 | if len(sys.argv) < 2: 15 | print(f"usage: spotify2ytmusic [COMMAND] <ARGUMENTS>") 16 | print("Available commands:", ", ".join(available_commands)) 17 | print(" For example, try 'spotify2ytmusic list_playlists'") 18 | sys.exit(1) 19 | 20 | if sys.argv[1] not in available_commands: 21 | print( 22 | f"ERROR: Unknown command '{sys.argv[1]}', see https://github.com/linsomniac/spotify_to_ytmusic" 23 | ) 24 | print("Available commands: ", ", ".join(available_commands)) 25 | sys.exit(1) 26 | 27 | fn = getattr(cli, sys.argv[1]) 28 | sys.argv = sys.argv[1:] 29 | fn() 30 | -------------------------------------------------------------------------------- /spotify2ytmusic/backend.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import sys 5 | import os 6 | import time 7 | import re 8 | 9 | from ytmusicapi import YTMusic 10 | from typing import Optional, Union, Iterator, Dict, List 11 | from collections import namedtuple 12 | from dataclasses import dataclass, field 13 | 14 | 15 | SongInfo = namedtuple("SongInfo", ["title", "artist", "album"]) 16 | 17 | 18 | def get_ytmusic() -> YTMusic: 19 | """ 20 | @@@ 21 | """ 22 | if not os.path.exists("oauth.json"): 23 | print("ERROR: No file 'oauth.json' exists in the current directory.") 24 | print(" Have you logged in to YTMusic? Run 'ytmusicapi oauth' to login") 25 | sys.exit(1) 26 | 27 | try: 28 | return YTMusic("oauth.json") 29 | except json.decoder.JSONDecodeError as e: 30 | print(f"ERROR: JSON Decode error while trying start YTMusic: {e}") 31 | print(" This typically means a problem with a 'oauth.json' file.") 32 | print(" Have you logged in to YTMusic? Run 'ytmusicapi oauth' to login") 33 | sys.exit(1) 34 | 35 | 36 | def _ytmusic_create_playlist( 37 | yt: YTMusic, title: str, description: str, privacy_status: str = "PRIVATE" 38 | ) -> str: 39 | """Wrapper on ytmusic.create_playlist 40 | 41 | This wrapper does retries with back-off because sometimes YouTube Music will 42 | rate limit requests or otherwise fail. 43 | 44 | privacy_status can be: PRIVATE, PUBLIC, or UNLISTED 45 | """ 46 | 47 | def _create( 48 | yt: YTMusic, title: str, description: str, privacy_status: str 49 | ) -> Union[str, dict]: 50 | exception_sleep = 5 51 | for _ in range(10): 52 | try: 53 | """Create a playlist on YTMusic, retrying if it fails.""" 54 | id = yt.create_playlist( 55 | title=title, description=description, privacy_status=privacy_status 56 | ) 57 | return id 58 | except Exception as e: 59 | print( 60 | f"ERROR: (Retrying create_playlist: {title}) {e} in {exception_sleep} seconds" 61 | ) 62 | time.sleep(exception_sleep) 63 | exception_sleep *= 2 64 | 65 | return { 66 | "s2yt error": 'ERROR: Could not create playlist "{title}" after multiple retries' 67 | } 68 | 69 | id = _create(yt, title, description, privacy_status) 70 | # create_playlist returns a dict if there was an error 71 | if isinstance(id, dict): 72 | print(f"ERROR: Failed to create playlist (name: {title}): {id}") 73 | sys.exit(1) 74 | 75 | time.sleep(1) # seems to be needed to avoid missing playlist ID error 76 | 77 | return id 78 | 79 | 80 | def load_playlists_json(filename: str = "playlists.json", encoding: str = "utf-8"): 81 | """Load the `playlists.json` Spotify playlist file""" 82 | return json.load(open(filename, "r", encoding=encoding)) 83 | 84 | 85 | def create_playlist(pl_name: str, privacy_status: str = "PRIVATE") -> None: 86 | """Create a YTMusic playlist 87 | 88 | 89 | Args: 90 | `pl_name` (str): The name of the playlist to create. It should be different to "". 91 | 92 | `privacy_status` (str: PRIVATE, PUBLIC, UNLISTED) The privacy setting of created playlist. 93 | """ 94 | yt = get_ytmusic() 95 | 96 | id = _ytmusic_create_playlist( 97 | yt, title=pl_name, description=pl_name, privacy_status=privacy_status 98 | ) 99 | print(f"Playlist ID: {id}") 100 | 101 | 102 | def iter_spotify_liked_albums( 103 | spotify_playlist_file: str = "playlists.json", 104 | spotify_encoding: str = "utf-8", 105 | ) -> Iterator[SongInfo]: 106 | """Songs from liked albums on Spotify.""" 107 | spotify_pls = load_playlists_json(spotify_playlist_file, spotify_encoding) 108 | 109 | if "albums" not in spotify_pls: 110 | return None 111 | 112 | for album in [x["album"] for x in spotify_pls["albums"]]: 113 | for track in album["tracks"]["items"]: 114 | yield SongInfo(track["name"], track["artists"][0]["name"], album["name"]) 115 | 116 | 117 | def iter_spotify_playlist( 118 | src_pl_id: Optional[str] = None, 119 | spotify_playlist_file: str = "playlists.json", 120 | spotify_encoding: str = "utf-8", 121 | reverse_playlist: bool = True, 122 | ) -> Iterator[SongInfo]: 123 | """Songs from a specific album ("Liked Songs" if None) 124 | 125 | Args: 126 | `src_pl_id` (Optional[str], optional): The ID of the source playlist. Defaults to None. 127 | `spotify_playlist_file` (str, optional): The path to the playlists backup files. Defaults to "playlists.json". 128 | `spotify_encoding` (str, optional): Characters encoding. Defaults to "utf-8". 129 | `reverse_playlist` (bool, optional): Is the playlist reversed when loading? Defaults to True. 130 | 131 | Yields: 132 | Iterator[SongInfo]: The song's information 133 | """ 134 | spotify_pls = load_playlists_json(spotify_playlist_file, spotify_encoding) 135 | 136 | def find_spotify_playlist(spotify_pls: Dict, src_pl_id: Union[str, None]) -> Dict: 137 | """Return the spotify playlist that matches the `src_pl_id`. 138 | 139 | Args: 140 | `spotify_pls`: The playlist datastrcuture saved by spotify-backup. 141 | `src_pl_id`: The ID of a playlist to find, or None for the "Liked Songs" playlist. 142 | """ 143 | for src_pl in spotify_pls["playlists"]: 144 | if src_pl_id is None and str(src_pl.get("name")) == "Liked Songs": 145 | return src_pl 146 | if src_pl_id is not None and str(src_pl.get("id")) == src_pl_id: 147 | return src_pl 148 | raise ValueError(f"Could not find Spotify playlist {src_pl_id}") 149 | 150 | src_pl = find_spotify_playlist(spotify_pls, src_pl_id) 151 | src_pl_name = src_pl["name"] 152 | 153 | print(f"== Spotify Playlist: {src_pl_name}") 154 | 155 | pl_tracks = src_pl["tracks"] 156 | if reverse_playlist: 157 | pl_tracks = reversed(pl_tracks) 158 | 159 | for src_track in pl_tracks: 160 | if src_track["track"] is None: 161 | print( 162 | f"WARNING: Spotify track seems to be malformed, Skipping. Track: {src_track!r}" 163 | ) 164 | continue 165 | 166 | try: 167 | src_album_name = src_track["track"]["album"]["name"] 168 | src_track_artist = src_track["track"]["artists"][0]["name"] 169 | except TypeError as e: 170 | print(f"ERROR: Spotify track seems to be malformed. Track: {src_track!r}") 171 | raise e 172 | src_track_name = src_track["track"]["name"] 173 | 174 | yield SongInfo(src_track_name, src_track_artist, src_album_name) 175 | 176 | 177 | def get_playlist_id_by_name(yt: YTMusic, title: str) -> Optional[str]: 178 | """Look up a YTMusic playlist ID by name. 179 | 180 | Args: 181 | `yt` (YTMusic): _description_ 182 | `title` (str): _description_ 183 | 184 | Returns: 185 | Optional[str]: The playlist ID or None if not found. 186 | """ 187 | # ytmusicapi seems to run into some situations where it gives a Traceback on listing playlists 188 | # https://github.com/sigma67/ytmusicapi/issues/539 189 | try: 190 | playlists = yt.get_library_playlists(limit=5000) 191 | except KeyError as e: 192 | print("=" * 60) 193 | print(f"Attempting to look up playlist '{title}' failed with KeyError: {e}") 194 | print( 195 | "This is a bug in ytmusicapi that prevents 'copy_all_playlists' from working." 196 | ) 197 | print( 198 | "You will need to manually copy playlists using s2yt_list_playlists and s2yt_copy_playlist" 199 | ) 200 | print( 201 | "until this bug gets resolved. Try `pip install --upgrade ytmusicapi` just to verify" 202 | ) 203 | print("you have the latest version of that library.") 204 | print("=" * 60) 205 | raise 206 | 207 | for pl in playlists: 208 | if pl["title"] == title: 209 | return pl["playlistId"] 210 | 211 | return None 212 | 213 | 214 | @dataclass 215 | class ResearchDetails: 216 | query: Optional[str] = field(default=None) 217 | songs: Optional[List[Dict]] = field(default=None) 218 | suggestions: Optional[List[str]] = field(default=None) 219 | 220 | 221 | def lookup_song( 222 | yt: YTMusic, 223 | track_name: str, 224 | artist_name: str, 225 | album_name, 226 | yt_search_algo: int, 227 | details: Optional[ResearchDetails] = None, 228 | ) -> dict: 229 | """Look up a song on YTMusic 230 | 231 | Given the Spotify track information, it does a lookup for the album by the same 232 | artist on YTMusic, then looks at the first 3 hits looking for a track with exactly 233 | the same name. In the event that it can't find that exact track, it then does 234 | a search of songs for the track name by the same artist and simply returns the 235 | first hit. 236 | 237 | The idea is that finding the album and artist and then looking for the exact track 238 | match will be more likely to be accurate than searching for the song and artist and 239 | relying on the YTMusic yt_search_algorithm to figure things out, especially for short tracks 240 | that might have many contradictory hits like "Survival by Yes". 241 | 242 | Args: 243 | `yt` (YTMusic) 244 | `track_name` (str): The name of the researched track 245 | `artist_name` (str): The name of the researched track's artist 246 | `album_name` (str): The name of the researched track's album 247 | `yt_search_algo` (int): 0 for exact matching, 1 for extended matching (search past 1st result), 2 for approximate matching (search in videos) 248 | `details` (ResearchDetails): If specified, more information about the search and the response will be populated for use by the caller. 249 | 250 | Raises: 251 | ValueError: If no track is found, it returns an error 252 | 253 | Returns: 254 | dict: The infos of the researched song 255 | """ 256 | albums = yt.search(query=f"{album_name} by {artist_name}", filter="albums") 257 | for album in albums[:3]: 258 | # print(album) 259 | # print(f"ALBUM: {album['browseId']} - {album['title']} - {album['artists'][0]['name']}") 260 | 261 | try: 262 | for track in yt.get_album(album["browseId"])["tracks"]: 263 | if track["title"] == track_name: 264 | return track 265 | # print(f"{track['videoId']} - {track['title']} - {track['artists'][0]['name']}") 266 | except Exception as e: 267 | print(f"Unable to lookup album ({e}), continuing...") 268 | 269 | query = f"{track_name} by {artist_name}" 270 | if details: 271 | details.query = query 272 | details.suggestions = yt.get_search_suggestions(query=query) 273 | songs = yt.search(query=query, filter="songs") 274 | 275 | match yt_search_algo: 276 | case 0: 277 | if details: 278 | details.songs = songs 279 | return songs[0] 280 | 281 | case 1: 282 | for song in songs: 283 | if ( 284 | song["title"] == track_name 285 | and song["artists"][0]["name"] == artist_name 286 | and song["album"]["name"] == album_name 287 | ): 288 | return song 289 | # print(f"SONG: {song['videoId']} - {song['title']} - {song['artists'][0]['name']} - {song['album']['name']}") 290 | 291 | raise ValueError( 292 | f"Did not find {track_name} by {artist_name} from {album_name}" 293 | ) 294 | 295 | case 2: 296 | # This would need to do fuzzy matching 297 | for song in songs: 298 | # Remove everything in brackets in the song title 299 | song_title_without_brackets = re.sub(r"[\[(].*?[])]", "", song["title"]) 300 | if ( 301 | ( 302 | song_title_without_brackets == track_name 303 | and song["album"]["name"] == album_name 304 | ) 305 | or (song_title_without_brackets == track_name) 306 | or (song_title_without_brackets in track_name) 307 | or (track_name in song_title_without_brackets) 308 | ) and ( 309 | song["artists"][0]["name"] == artist_name 310 | or artist_name in song["artists"][0]["name"] 311 | ): 312 | return song 313 | 314 | # Finds approximate match 315 | # This tries to find a song anyway. Works when the song is not released as a music but a video. 316 | else: 317 | track_name = track_name.lower() 318 | first_song_title = songs[0]["title"].lower() 319 | if ( 320 | track_name not in first_song_title 321 | or songs[0]["artists"][0]["name"] != artist_name 322 | ): # If the first song is not the one we are looking for 323 | print("Not found in songs, searching videos") 324 | new_songs = yt.search( 325 | query=f"{track_name} by {artist_name}", filter="videos" 326 | ) # Search videos 327 | 328 | # From here, we search for videos reposting the song. They often contain the name of it and the artist. Like with 'Nekfeu - Ecrire'. 329 | for new_song in new_songs: 330 | new_song_title = new_song[ 331 | "title" 332 | ].lower() # People sometimes mess up the capitalization in the title 333 | if ( 334 | track_name in new_song_title 335 | and artist_name in new_song_title 336 | ) or (track_name in new_song_title): 337 | print("Found a video") 338 | return new_song 339 | else: 340 | # Basically we only get here if the song isn't present anywhere on YouTube 341 | raise ValueError( 342 | f"Did not find {track_name} by {artist_name} from {album_name}" 343 | ) 344 | else: 345 | return songs[0] 346 | 347 | 348 | def copier( 349 | src_tracks: Iterator[SongInfo], 350 | dst_pl_id: Optional[str] = None, 351 | dry_run: bool = False, 352 | track_sleep: float = 0.1, 353 | yt_search_algo: int = 0, 354 | *, 355 | yt: Optional[YTMusic] = None, 356 | ): 357 | """ 358 | @@@ 359 | """ 360 | if yt is None: 361 | yt = get_ytmusic() 362 | 363 | if dst_pl_id is not None: 364 | try: 365 | yt_pl = yt.get_playlist(playlistId=dst_pl_id) 366 | except Exception as e: 367 | print(f"ERROR: Unable to find YTMusic playlist {dst_pl_id}: {e}") 368 | print( 369 | " Make sure the YTMusic playlist ID is correct, it should be something like " 370 | ) 371 | print(" 'PL_DhcdsaJ7echjfdsaJFhdsWUd73HJFca'") 372 | sys.exit(1) 373 | print(f"== Youtube Playlist: {yt_pl['title']}") 374 | 375 | tracks_added_set = set() 376 | duplicate_count = 0 377 | error_count = 0 378 | 379 | for src_track in src_tracks: 380 | print(f"Spotify: {src_track.title} - {src_track.artist} - {src_track.album}") 381 | 382 | try: 383 | dst_track = lookup_song( 384 | yt, src_track.title, src_track.artist, src_track.album, yt_search_algo 385 | ) 386 | except Exception as e: 387 | print(f"ERROR: Unable to look up song on YTMusic: {e}") 388 | error_count += 1 389 | continue 390 | 391 | yt_artist_name = "<Unknown>" 392 | if "artists" in dst_track and len(dst_track["artists"]) > 0: 393 | yt_artist_name = dst_track["artists"][0]["name"] 394 | print( 395 | f" Youtube: {dst_track['title']} - {yt_artist_name} - {dst_track['album'] if 'album' in dst_track else '<Unknown>'}" 396 | ) 397 | 398 | if dst_track["videoId"] in tracks_added_set: 399 | print("(DUPLICATE, this track has already been added)") 400 | duplicate_count += 1 401 | tracks_added_set.add(dst_track["videoId"]) 402 | 403 | if not dry_run: 404 | exception_sleep = 5 405 | for _ in range(10): 406 | try: 407 | if dst_pl_id is not None: 408 | yt.add_playlist_items( 409 | playlistId=dst_pl_id, 410 | videoIds=[dst_track["videoId"]], 411 | duplicates=False, 412 | ) 413 | else: 414 | yt.rate_song(dst_track["videoId"], "LIKE") 415 | break 416 | except Exception as e: 417 | print( 418 | f"ERROR: (Retrying add_playlist_items: {dst_pl_id} {dst_track['videoId']}) {e} in {exception_sleep} seconds" 419 | ) 420 | time.sleep(exception_sleep) 421 | exception_sleep *= 2 422 | 423 | if track_sleep: 424 | time.sleep(track_sleep) 425 | 426 | print() 427 | print( 428 | f"Added {len(tracks_added_set)} tracks, encountered {duplicate_count} duplicates, {error_count} errors" 429 | ) 430 | 431 | 432 | def copy_playlist( 433 | spotify_playlist_id: str, 434 | ytmusic_playlist_id: str, 435 | spotify_playlists_encoding: str = "utf-8", 436 | dry_run: bool = False, 437 | track_sleep: float = 0.1, 438 | yt_search_algo: int = 0, 439 | reverse_playlist: bool = True, 440 | privacy_status: str = "PRIVATE", 441 | ): 442 | """ 443 | Copy a Spotify playlist to a YTMusic playlist 444 | @@@ 445 | """ 446 | print("Using search algo n°: ", yt_search_algo) 447 | yt = get_ytmusic() 448 | pl_name: str = "" 449 | 450 | if ytmusic_playlist_id.startswith("+"): 451 | pl_name = ytmusic_playlist_id[1:] 452 | 453 | ytmusic_playlist_id = get_playlist_id_by_name(yt, pl_name) 454 | print(f"Looking up playlist '{pl_name}': id={ytmusic_playlist_id}") 455 | 456 | if ytmusic_playlist_id is None: 457 | if pl_name == "": 458 | print("No playlist name or ID provided, creating playlist...") 459 | spotify_pls: dict = load_playlists_json() 460 | for pl in spotify_pls["playlists"]: 461 | if len(pl.keys()) > 3 and pl["id"] == spotify_playlist_id: 462 | pl_name = pl["name"] 463 | 464 | ytmusic_playlist_id = _ytmusic_create_playlist( 465 | yt, 466 | title=pl_name, 467 | description=pl_name, 468 | privacy_status=privacy_status, 469 | ) 470 | 471 | # create_playlist returns a dict if there was an error 472 | if isinstance(ytmusic_playlist_id, dict): 473 | print(f"ERROR: Failed to create playlist: {ytmusic_playlist_id}") 474 | sys.exit(1) 475 | print(f"NOTE: Created playlist '{pl_name}' with ID: {ytmusic_playlist_id}") 476 | 477 | copier( 478 | iter_spotify_playlist( 479 | spotify_playlist_id, 480 | spotify_encoding=spotify_playlists_encoding, 481 | reverse_playlist=reverse_playlist, 482 | ), 483 | ytmusic_playlist_id, 484 | dry_run, 485 | track_sleep, 486 | yt_search_algo, 487 | yt=yt, 488 | ) 489 | 490 | 491 | def copy_all_playlists( 492 | track_sleep: float = 0.1, 493 | dry_run: bool = False, 494 | spotify_playlists_encoding: str = "utf-8", 495 | yt_search_algo: int = 0, 496 | reverse_playlist: bool = True, 497 | privacy_status: str = "PRIVATE", 498 | ): 499 | """ 500 | Copy all Spotify playlists (except Liked Songs) to YTMusic playlists 501 | """ 502 | spotify_pls = load_playlists_json() 503 | yt = get_ytmusic() 504 | 505 | for src_pl in spotify_pls["playlists"]: 506 | if str(src_pl.get("name")) == "Liked Songs": 507 | continue 508 | 509 | pl_name = src_pl["name"] 510 | if pl_name == "": 511 | pl_name = f"Unnamed Spotify Playlist {src_pl['id']}" 512 | 513 | dst_pl_id = get_playlist_id_by_name(yt, pl_name) 514 | print(f"Looking up playlist '{pl_name}': id={dst_pl_id}") 515 | if dst_pl_id is None: 516 | dst_pl_id = _ytmusic_create_playlist( 517 | yt, title=pl_name, description=pl_name, privacy_status=privacy_status 518 | ) 519 | 520 | # create_playlist returns a dict if there was an error 521 | if isinstance(dst_pl_id, dict): 522 | print(f"ERROR: Failed to create playlist: {dst_pl_id}") 523 | sys.exit(1) 524 | print(f"NOTE: Created playlist '{pl_name}' with ID: {dst_pl_id}") 525 | 526 | copier( 527 | iter_spotify_playlist( 528 | src_pl["id"], 529 | spotify_encoding=spotify_playlists_encoding, 530 | reverse_playlist=reverse_playlist, 531 | ), 532 | dst_pl_id, 533 | dry_run, 534 | track_sleep, 535 | yt_search_algo, 536 | ) 537 | print("\nPlaylist done!\n") 538 | 539 | print("All done!") 540 | -------------------------------------------------------------------------------- /spotify2ytmusic/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | from argparse import ArgumentParser 5 | import pprint 6 | 7 | from . import backend 8 | 9 | 10 | def list_liked_albums(): 11 | """ 12 | List albums that have been liked. 13 | """ 14 | for song in backend.iter_spotify_liked_albums(): 15 | print(f"{song.album} - {song.artist} - {song.title}") 16 | 17 | 18 | def list_playlists(): 19 | """ 20 | List the playlists on Spotify and YTMusic 21 | """ 22 | yt = backend.get_ytmusic() 23 | 24 | spotify_pls = backend.load_playlists_json() 25 | 26 | # Liked music 27 | print("== Spotify") 28 | for src_pl in spotify_pls["playlists"]: 29 | print( 30 | f"{src_pl.get('id')} - {src_pl['name']:50} ({len(src_pl['tracks'])} tracks)" 31 | ) 32 | 33 | print() 34 | print("== YTMusic") 35 | for pl in yt.get_library_playlists(limit=5000): 36 | print(f"{pl['playlistId']} - {pl['title']:40} ({pl.get('count', '?')} tracks)") 37 | 38 | 39 | def create_playlist(): 40 | """ 41 | Create a YTMusic playlist 42 | """ 43 | 44 | def parse_arguments(): 45 | parser = ArgumentParser() 46 | parser.add_argument( 47 | "--privacy", 48 | default="PRIVATE", 49 | help="The privacy seting of created playlists (PRIVATE, PUBLIC, UNLISTED, default PRIVATE)", 50 | ) 51 | parser.add_argument( 52 | "playlist_name", 53 | type=str, 54 | help="Name of playlist to create.", 55 | ) 56 | 57 | return parser.parse_args() 58 | 59 | args = parse_arguments() 60 | 61 | backend.create_playlist(args.playlist_name, privacy_status=args.privacy) 62 | 63 | 64 | def search(): 65 | """Search for a track on ytmusic""" 66 | 67 | def parse_arguments(): 68 | parser = ArgumentParser() 69 | parser.add_argument( 70 | "track_name", 71 | type=str, 72 | help="Name of track to search for", 73 | ) 74 | parser.add_argument( 75 | "--artist", 76 | type=str, 77 | help="Artist to look up", 78 | ) 79 | parser.add_argument( 80 | "--album", 81 | type=str, 82 | help="Album name", 83 | ) 84 | parser.add_argument( 85 | "--algo", 86 | type=int, 87 | default=0, 88 | help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", 89 | ) 90 | return parser.parse_args() 91 | 92 | args = parse_arguments() 93 | 94 | yt = backend.get_ytmusic() 95 | details = backend.ResearchDetails() 96 | ret = backend.lookup_song( 97 | yt, args.track_name, args.artist, args.album, args.algo, details=details 98 | ) 99 | 100 | print(f"Query: '{details.query}'") 101 | print("Selected song:") 102 | pprint.pprint(ret) 103 | print() 104 | print(f"Search Suggestions: '{details.suggestions}'") 105 | if details.songs: 106 | print("Top 5 songs returned from search:") 107 | for song in details.songs[:5]: 108 | pprint.pprint(song) 109 | 110 | 111 | def load_liked_albums(): 112 | """ 113 | Load the "Liked" albums from Spotify into YTMusic. Spotify stores liked albums separately 114 | from liked songs, so "load_liked" does not see the albums, you instead need to use this. 115 | """ 116 | 117 | def parse_arguments(): 118 | parser = ArgumentParser() 119 | parser.add_argument( 120 | "--track-sleep", 121 | type=float, 122 | default=0.1, 123 | help="Time to sleep between each track that is added (default: 0.1)", 124 | ) 125 | parser.add_argument( 126 | "--dry-run", 127 | action="store_true", 128 | help="Do not add songs to destination playlist (default: False)", 129 | ) 130 | parser.add_argument( 131 | "--spotify-playlists-encoding", 132 | default="utf-8", 133 | help="The encoding of the `playlists.json` file.", 134 | ) 135 | parser.add_argument( 136 | "--algo", 137 | type=int, 138 | default=0, 139 | help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", 140 | ) 141 | 142 | return parser.parse_args() 143 | 144 | args = parse_arguments() 145 | 146 | spotify_pls = backend.load_playlists_json() 147 | 148 | backend.copier( 149 | backend.iter_spotify_liked_albums( 150 | spotify_encoding=args.spotify_playlists_encoding 151 | ), 152 | None, 153 | args.dry_run, 154 | args.track_sleep, 155 | args.algo, 156 | ) 157 | 158 | 159 | def load_liked(): 160 | """ 161 | Load the "Liked Songs" playlist from Spotify into YTMusic. 162 | """ 163 | 164 | def parse_arguments(): 165 | parser = ArgumentParser() 166 | parser.add_argument( 167 | "--track-sleep", 168 | type=float, 169 | default=0.1, 170 | help="Time to sleep between each track that is added (default: 0.1)", 171 | ) 172 | parser.add_argument( 173 | "--dry-run", 174 | action="store_true", 175 | help="Do not add songs to destination playlist (default: False)", 176 | ) 177 | parser.add_argument( 178 | "--spotify-playlists-encoding", 179 | default="utf-8", 180 | help="The encoding of the `playlists.json` file.", 181 | ) 182 | parser.add_argument( 183 | "--algo", 184 | type=int, 185 | default=0, 186 | help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", 187 | ) 188 | parser.add_argument( 189 | "--reverse-playlist", 190 | action="store_true", 191 | help="Reverse playlist on load, normally this is not set for liked songs as " 192 | "they are added in the opposite order from other commands in this program.", 193 | ) 194 | 195 | return parser.parse_args() 196 | 197 | args = parse_arguments() 198 | 199 | backend.copier( 200 | backend.iter_spotify_playlist( 201 | None, 202 | spotify_encoding=args.spotify_playlists_encoding, 203 | reverse_playlist=args.reverse_playlist, 204 | ), 205 | None, 206 | args.dry_run, 207 | args.track_sleep, 208 | args.algo, 209 | ) 210 | 211 | 212 | def copy_playlist(): 213 | """ 214 | Copy a Spotify playlist to a YTMusic playlist 215 | """ 216 | 217 | def parse_arguments(): 218 | parser = ArgumentParser() 219 | parser.add_argument( 220 | "--track-sleep", 221 | type=float, 222 | default=0.1, 223 | help="Time to sleep between each track that is added (default: 0.1)", 224 | ) 225 | parser.add_argument( 226 | "--dry-run", 227 | action="store_true", 228 | help="Do not add songs to destination playlist (default: False)", 229 | ) 230 | parser.add_argument( 231 | "spotify_playlist_id", 232 | type=str, 233 | help="ID of the Spotify playlist to copy from", 234 | ) 235 | parser.add_argument( 236 | "ytmusic_playlist_id", 237 | type=str, 238 | help="ID of the YTMusic playlist to copy to. If this argument starts with a '+', it is asumed to be the playlist title rather than playlist ID, and if a playlist of that name is not found, it will be created (without the +). Example: '+My Favorite Blues'. NOTE: The shell will require you to quote the name if it contains spaces.", 239 | ) 240 | parser.add_argument( 241 | "--spotify-playlists-encoding", 242 | default="utf-8", 243 | help="The encoding of the `playlists.json` file.", 244 | ) 245 | parser.add_argument( 246 | "--algo", 247 | type=int, 248 | default=0, 249 | help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", 250 | ) 251 | parser.add_argument( 252 | "--no-reverse-playlist", 253 | action="store_true", 254 | help="Do not reverse playlist on load, regular playlists are reversed normally " 255 | "so they end up in the same order as on Spotify.", 256 | ) 257 | parser.add_argument( 258 | "--privacy", 259 | default="PRIVATE", 260 | help="The privacy seting of created playlists (PRIVATE, PUBLIC, UNLISTED, default PRIVATE)", 261 | ) 262 | 263 | return parser.parse_args() 264 | 265 | args = parse_arguments() 266 | backend.copy_playlist( 267 | spotify_playlist_id=args.spotify_playlist_id, 268 | ytmusic_playlist_id=args.ytmusic_playlist_id, 269 | track_sleep=args.track_sleep, 270 | dry_run=args.dry_run, 271 | spotify_playlists_encoding=args.spotify_playlists_encoding, 272 | reverse_playlist=not args.no_reverse_playlist, 273 | privacy_status=args.privacy, 274 | ) 275 | 276 | 277 | def copy_all_playlists(): 278 | """ 279 | Copy all Spotify playlists (except Liked Songs) to YTMusic playlists 280 | """ 281 | 282 | def parse_arguments(): 283 | parser = ArgumentParser() 284 | parser.add_argument( 285 | "--track-sleep", 286 | type=float, 287 | default=0.1, 288 | help="Time to sleep between each track that is added (default: 0.1)", 289 | ) 290 | parser.add_argument( 291 | "--dry-run", 292 | action="store_true", 293 | help="Do not add songs to destination playlist (default: False)", 294 | ) 295 | parser.add_argument( 296 | "--spotify-playlists-encoding", 297 | default="utf-8", 298 | help="The encoding of the `playlists.json` file.", 299 | ) 300 | parser.add_argument( 301 | "--algo", 302 | type=int, 303 | default=0, 304 | help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", 305 | ) 306 | parser.add_argument( 307 | "--no-reverse-playlist", 308 | action="store_true", 309 | help="Do not reverse playlist on load, regular playlists are reversed normally " 310 | "so they end up in the same order as on Spotify.", 311 | ) 312 | parser.add_argument( 313 | "--privacy", 314 | default="PRIVATE", 315 | help="The privacy seting of created playlists (PRIVATE, PUBLIC, UNLISTED, default PRIVATE)", 316 | ) 317 | 318 | return parser.parse_args() 319 | 320 | args = parse_arguments() 321 | backend.copy_all_playlists( 322 | track_sleep=args.track_sleep, 323 | dry_run=args.dry_run, 324 | spotify_playlists_encoding=args.spotify_playlists_encoding, 325 | reverse_playlist=not args.no_reverse_playlist, 326 | privacy_status=args.privacy, 327 | ) 328 | 329 | 330 | def gui(): 331 | """ 332 | Run the Spotify2YTMusic GUI. 333 | """ 334 | from . import gui 335 | 336 | gui.main() 337 | 338 | 339 | def ytoauth(): 340 | """ 341 | Run the "ytmusicapi oauth" login. 342 | """ 343 | from ytmusicapi.setup import main 344 | 345 | sys.argv = ["ytmusicapi", "oauth"] 346 | sys.exit(main()) 347 | -------------------------------------------------------------------------------- /spotify2ytmusic/gui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | import threading 7 | import json 8 | import tkinter as tk 9 | from tkinter import ttk 10 | 11 | from . import cli 12 | from . import backend 13 | from . import spotify_backup 14 | from typing import Callable 15 | 16 | 17 | def create_label(parent: tk.Frame, text: str, **kwargs) -> tk.Label: 18 | """Simply creates a label with the given text and the given parent. 19 | 20 | Args: 21 | parent (tk.Frame): The parent of the label. 22 | text (str): The text of the label. 23 | 24 | Returns: 25 | tk.Label: The label created. 26 | """ 27 | return tk.Label( 28 | parent, 29 | text=text, 30 | font=("Helvetica", 14), 31 | background="#26242f", 32 | foreground="white", 33 | **kwargs, 34 | ) 35 | 36 | 37 | def create_button(parent: tk.Frame, text: str, **kwargs) -> tk.Button: 38 | """Simply creates a button with the given text and the given parent. 39 | 40 | Args: 41 | parent (tk.Frame): The parent of the button. 42 | text (str): The text of the button. 43 | 44 | Returns: 45 | tk.Button: The button created. 46 | """ 47 | return tk.Button( 48 | parent, 49 | text=text, 50 | font=("Helvetica", 14), 51 | background="#696969", 52 | foreground="white", 53 | border=1, 54 | **kwargs, 55 | ) 56 | 57 | 58 | class Window: 59 | """The main window of the application. It contains the tabs and the logs.""" 60 | 61 | def __init__(self) -> None: 62 | """Initializes the main window of the application. It contains the tabs and the logs.""" 63 | self.root = tk.Tk() 64 | self.root.title("Spotify to YT Music") 65 | self.root.geometry("1280x720") 66 | self.root.config(background="#26242f") 67 | 68 | style = ttk.Style() 69 | style.theme_use("default") 70 | style.configure( 71 | "TNotebook.Tab", background="#121212", foreground="white" 72 | ) # Set the background color to #121212 when not selected 73 | style.map( 74 | "TNotebook.Tab", 75 | background=[("selected", "#26242f")], 76 | foreground=[("selected", "#ffffff")], 77 | ) # Set the background color to #26242f and text color to white when selected 78 | style.configure("TFrame", background="#26242f") 79 | style.configure("TNotebook", background="#121212") 80 | 81 | # Redirect stdout to GUI 82 | sys.stdout.write = self.redirector 83 | 84 | self.root.after(1, lambda: self.yt_login(auto=True)) 85 | self.root.after(1, lambda: self.load_write_settings(0)) 86 | 87 | # Create a PanedWindow with vertical orientation 88 | self.paned_window = ttk.PanedWindow(self.root, orient=tk.VERTICAL) 89 | self.paned_window.pack(fill=tk.BOTH, expand=1) 90 | 91 | # Create a Frame for the tabs 92 | self.tab_frame = ttk.Frame(self.paned_window) 93 | self.paned_window.add(self.tab_frame, weight=2) 94 | 95 | # Create the TabControl (notebook) 96 | self.tabControl = ttk.Notebook(self.tab_frame) 97 | self.tabControl.pack(fill=tk.BOTH, expand=1) 98 | 99 | # Create the tabs 100 | self.tab1 = ttk.Frame(self.tabControl) 101 | self.tab2 = ttk.Frame(self.tabControl) 102 | # self.tab2 = ttk.Frame(self.tabControl) 103 | self.tab3 = ttk.Frame(self.tabControl) 104 | self.tab4 = ttk.Frame(self.tabControl) 105 | self.tab5 = ttk.Frame(self.tabControl) 106 | self.tab6 = ttk.Frame(self.tabControl) 107 | self.tab7 = ttk.Frame(self.tabControl) 108 | 109 | self.tabControl.add(self.tab1, text="Login to YT Music") 110 | self.tabControl.add(self.tab2, text="Spotify backup") 111 | 112 | # self.tabControl.add(self.tab2, text='Reverse playlist') 113 | self.tabControl.add(self.tab3, text="Load liked songs") 114 | self.tabControl.add(self.tab4, text="List playlists") 115 | self.tabControl.add(self.tab5, text="Copy all playlists") 116 | self.tabControl.add(self.tab6, text="Copy a specific playlist") 117 | self.tabControl.add(self.tab7, text="Settings") 118 | 119 | # Create a Frame for the logs 120 | self.log_frame = ttk.Frame(self.paned_window) 121 | self.paned_window.add(self.log_frame, weight=1) 122 | 123 | # Create the Text widget for the logs 124 | self.logs = tk.Text(self.log_frame, font=("Helvetica", 14)) 125 | self.logs.pack(fill=tk.BOTH, expand=1) 126 | self.logs.config(background="#26242f", foreground="white") 127 | 128 | # tab1 129 | create_label( 130 | self.tab1, 131 | text="Welcome to Spotify to YT Music!\nTo start, you need to login to YT Music.", 132 | ).pack(anchor=tk.CENTER, expand=True) 133 | create_button(self.tab1, text="Login", command=self.yt_login).pack( 134 | anchor=tk.CENTER, expand=True 135 | ) 136 | 137 | # tab2 138 | 139 | create_label( 140 | self.tab2, text="First, you need to backup your spotify playlists" 141 | ).pack(anchor=tk.CENTER, expand=True) 142 | create_button( 143 | self.tab2, 144 | text="Backup", 145 | command=lambda: self.call_func( 146 | func=spotify_backup.main, args=(), next_tab=self.tab3 147 | ), 148 | ).pack(anchor=tk.CENTER, expand=True) 149 | 150 | # tab3 151 | create_label(self.tab3, text="Now, you can load your liked songs.").pack( 152 | anchor=tk.CENTER, expand=True 153 | ) 154 | create_button( 155 | self.tab3, 156 | text="Load", 157 | command=lambda: self.call_func( 158 | func=backend.copier, 159 | args=( 160 | backend.iter_spotify_playlist(), 161 | None, 162 | False, 163 | 0.1, 164 | self.var_algo.get(), 165 | ), 166 | next_tab=self.tab4, 167 | ), 168 | ).pack(anchor=tk.CENTER, expand=True) 169 | 170 | # tab4 171 | create_label( 172 | self.tab4, text="Here, you can get a list of your playlists, with their ID." 173 | ).pack(anchor=tk.CENTER, expand=True) 174 | create_button( 175 | self.tab4, 176 | text="List", 177 | command=lambda: self.call_func( 178 | func=cli.list_playlists, args=(), next_tab=self.tab5 179 | ), 180 | ).pack(anchor=tk.CENTER, expand=True) 181 | 182 | # tab5 183 | create_label( 184 | self.tab5, 185 | text="Here, you can copy all your playlists from Spotify to YT Music. Please note that this step " 186 | "can take a long time since songs are added one by one.", 187 | ).pack(anchor=tk.CENTER, expand=True) 188 | create_button( 189 | self.tab5, 190 | text="Copy", 191 | command=lambda: self.call_func( 192 | func=backend.copy_all_playlists, 193 | args=(0.1, False, "utf-8", self.var_algo.get()), 194 | next_tab=self.tab6, 195 | ), 196 | ).pack(anchor=tk.CENTER, expand=True) 197 | 198 | # tab6 199 | create_label( 200 | self.tab6, 201 | text="Here, you can copy a specific playlist from Spotify to YT Music.", 202 | ).pack(anchor=tk.CENTER, expand=True) 203 | create_label(self.tab6, text="Spotify playlist ID:").pack( 204 | anchor=tk.CENTER, expand=True 205 | ) 206 | self.spotify_playlist_id = tk.Entry(self.tab6) 207 | self.spotify_playlist_id.pack(anchor=tk.CENTER, expand=True) 208 | create_label(self.tab6, text="YT Music playlist ID:").pack( 209 | anchor=tk.CENTER, expand=True 210 | ) 211 | self.yt_playlist_id = tk.Entry(self.tab6) 212 | self.yt_playlist_id.pack(anchor=tk.CENTER, expand=True) 213 | create_button( 214 | self.tab6, 215 | text="Copy", 216 | command=lambda: self.call_func( 217 | func=backend.copy_playlist, 218 | args=( 219 | self.spotify_playlist_id.get(), 220 | self.yt_playlist_id.get(), 221 | "utf-8", 222 | False, 223 | 0.1, 224 | self.var_algo.get(), 225 | ), 226 | next_tab=self.tab6, 227 | ), 228 | ).pack(anchor=tk.CENTER, expand=True) 229 | 230 | # tab7 231 | self.var_scroll = tk.BooleanVar() 232 | 233 | auto_scroll = tk.Checkbutton( 234 | self.tab7, 235 | text="Auto scroll", 236 | variable=self.var_scroll, 237 | command=lambda: self.load_write_settings(1), 238 | background="#696969", 239 | foreground="#ffffff", 240 | selectcolor="#26242f", 241 | border=1, 242 | ) 243 | auto_scroll.pack(anchor=tk.CENTER, expand=True) 244 | auto_scroll.select() 245 | 246 | self.var_algo = tk.IntVar() 247 | self.var_algo.set(0) 248 | 249 | self.algo_label = create_label(self.tab7, text=f"Algorithm: ") 250 | self.algo_label.pack(anchor=tk.CENTER, expand=True) 251 | 252 | menu_algo = tk.OptionMenu( 253 | self.tab7, 254 | self.var_algo, 255 | 0, 256 | *[1, 2], 257 | command=lambda x: self.load_write_settings(1), 258 | ) 259 | menu_algo.pack(anchor=tk.CENTER, expand=True) 260 | menu_algo.config(background="#696969", foreground="#ffffff", border=1) 261 | 262 | def redirector(self, input_str="") -> None: 263 | """ 264 | Inserts the input string into the logs widget and disables editing. 265 | 266 | Args: 267 | self: The instance of the class. 268 | input_str (str): The string to be inserted into the logs' widget. 269 | """ 270 | self.logs.config(state=tk.NORMAL) 271 | self.logs.insert(tk.END, input_str) 272 | self.logs.config(state=tk.DISABLED) 273 | if self.var_scroll.get(): 274 | self.logs.see(tk.END) 275 | 276 | def call_func(self, func: Callable, args: tuple, next_tab: ttk.Frame) -> None: 277 | """Calls the given function in a separate thread and switches to the next tab when the function is done. 278 | 279 | Args: 280 | func (Callable): The function to be called. 281 | args (tuple): The arguments to be passed to the function. If no arguments are needed, pass an empty tuple. 282 | next_tab (ttk.Frame): The tab to switch to when the function is done. If no switch needed, pass the current one. 283 | """ 284 | th = threading.Thread(target=func, args=args) 285 | th.start() 286 | while th.is_alive(): 287 | self.root.update() 288 | self.tabControl.select(next_tab) 289 | print() 290 | 291 | def yt_login(self, auto=False) -> None: 292 | """Logs in to YT Music. If the oauth.json file is not found, it opens a new console window to run the 'ytmusicapi oauth' command. 293 | 294 | Args: 295 | auto (bool, optional): Weather to automatically login using the oauth.json file. Defaults to False. 296 | """ 297 | 298 | def run_in_thread(): 299 | if os.path.exists("oauth.json"): 300 | print("File detected, auto login") 301 | elif auto: 302 | print("No file detected. Manual login required") 303 | return 304 | else: 305 | print("File not detected, login required") 306 | 307 | # Open a new console window to run the command 308 | if os.name == "nt": # If the OS is Windows 309 | try: 310 | process = subprocess.Popen( 311 | ["ytmusicapi", "oauth"], 312 | creationflags=subprocess.CREATE_NEW_CONSOLE, 313 | ) 314 | except FileNotFoundError as e: 315 | print( 316 | f"ERROR: Unable to run 'ytmusicapi oauth'. Is ytmusicapi installed? Perhaps try running 'pip install ytmusicapi' Exception: {e}" 317 | ) 318 | sys.exit(1) 319 | process.communicate() 320 | else: # For Unix and Linux 321 | try: 322 | subprocess.call( 323 | "python3 -m ytmusicapi oauth", 324 | shell=True, 325 | stdout=subprocess.PIPE, 326 | ) 327 | except Exception as e: 328 | print(f"An error occurred: {e}") 329 | 330 | 331 | self.tabControl.select(self.tab2) 332 | print() 333 | 334 | # Run the function in a separate thread 335 | th = threading.Thread(target=run_in_thread) 336 | th.start() 337 | 338 | def load_write_settings(self, action: int) -> None: 339 | """Loads or writes the settings to the settings.json file. 340 | 341 | Args: 342 | action (int): 0 to load the settings, 1 to write the settings. 343 | """ 344 | texts = {0: "Exact match", 1: "Fuzzy match", 2: "Fuzzy match with videos"} 345 | 346 | exist = True 347 | if action == 0: 348 | with open("settings.json", "a+"): 349 | pass 350 | with open("settings.json", "r+") as f: 351 | value = f.read() 352 | if value == "": 353 | exist = False 354 | if exist: 355 | with open("settings.json", "r+") as f: 356 | settings = json.load(f) 357 | self.var_scroll.set(settings["auto_scroll"]) 358 | self.var_algo.set(settings["algo_number"]) 359 | else: 360 | with open("settings.json", "w+") as f: 361 | settings = { 362 | "auto_scroll": self.var_scroll.get(), 363 | "algo_number": self.var_algo.get(), 364 | } 365 | json.dump(settings, f) 366 | 367 | self.algo_label.config(text=f"Algorithm: {texts[self.var_algo.get()]}") 368 | self.root.update() 369 | 370 | 371 | def main() -> None: 372 | ui = Window() 373 | ui.root.mainloop() 374 | 375 | 376 | if __name__ == "__main__": 377 | main() 378 | -------------------------------------------------------------------------------- /spotify2ytmusic/reverse_playlist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import os 5 | import shutil 6 | from argparse import ArgumentParser 7 | 8 | 9 | def reverse_playlist(input_file="playlists.json", verbose=True, replace=False) -> int: 10 | if os.path.exists(input_file) and not replace: 11 | if verbose: 12 | print( 13 | "Output file already exists and no replace argument detected, exiting..." 14 | ) 15 | return 1 16 | 17 | print("Backing up file...") 18 | shutil.copyfile(input_file, input_file.split(".")[0] + "_backup.json") 19 | # Load the JSON file 20 | with open(input_file, "r") as file: 21 | if verbose: 22 | print("Loading initial JSON file...") 23 | data = json.load(file) 24 | 25 | # Copy the data to a new dictionary 26 | data2 = data.copy() 27 | 28 | if verbose: 29 | print("Reversing playlists...") 30 | # Reverse the order of items in the "tracks" list 31 | for i in range(len(data2["playlists"])): 32 | # Reverse the tracks in the playlist 33 | data2["playlists"][i]["tracks"] = data2["playlists"][i]["tracks"][::-1] 34 | 35 | if verbose: 36 | print("Writing to file... (this can take a while)") 37 | # Write the modified JSON back to the file 38 | with open(input_file, "w") as file: 39 | json.dump(data2, file) 40 | 41 | if verbose: 42 | print("Done!") 43 | print(f"File can be found at {input_file}") 44 | 45 | return 0 46 | 47 | 48 | if __name__ == "__main__": 49 | parser = ArgumentParser() 50 | parser.add_argument("input_file", type=str, help="Path to the input file") 51 | parser.add_argument( 52 | "-v", "--verbose", action="store_false", help="Enable verbose mode" 53 | ) 54 | parser.add_argument( 55 | "-r", 56 | "--replace", 57 | action="store_true", 58 | help="Replace the output file if already existing", 59 | ) 60 | 61 | args = parser.parse_args() 62 | 63 | reverse_playlist(args.input_file, args.verbose, args.replace) 64 | -------------------------------------------------------------------------------- /spotify2ytmusic/settings.json: -------------------------------------------------------------------------------- 1 | {"auto_scroll": true, "algo_number": 2} -------------------------------------------------------------------------------- /spotify2ytmusic/spotify_backup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This file is licensed under the MIT license 4 | # This file originates from https://github.com/caseychu/spotify-backup 5 | 6 | import codecs 7 | import http.client 8 | import http.server 9 | import json 10 | import re 11 | import sys 12 | import time 13 | import urllib.error 14 | import urllib.parse 15 | import urllib.request 16 | import webbrowser 17 | 18 | 19 | class SpotifyAPI: 20 | """Class to interact with the Spotify API using an OAuth token.""" 21 | 22 | BASE_URL = "https://api.spotify.com/v1/" 23 | 24 | def __init__(self, auth): 25 | self._auth = auth 26 | 27 | def get(self, url, params={}, tries=3): 28 | """Fetch a resource from Spotify API.""" 29 | url = self._construct_url(url, params) 30 | for _ in range(tries): 31 | try: 32 | req = self._create_request(url) 33 | return self._read_response(req) 34 | except Exception as err: 35 | print(f"Error fetching URL {url}: {err}") 36 | time.sleep(2) 37 | sys.exit("Failed to fetch data from Spotify API after retries.") 38 | 39 | def list(self, url, params={}): 40 | """Fetch paginated resources and return as a combined list.""" 41 | response = self.get(url, params) 42 | items = response["items"] 43 | 44 | while response["next"]: 45 | response = self.get(response["next"]) 46 | items += response["items"] 47 | return items 48 | 49 | @staticmethod 50 | def authorize(client_id, scope): 51 | """Open a browser for user authorization and return SpotifyAPI instance.""" 52 | redirect_uri = f"http://127.0.0.1:{SpotifyAPI._SERVER_PORT}/redirect" 53 | url = SpotifyAPI._construct_auth_url(client_id, scope, redirect_uri) 54 | print(f"Open this link if the browser doesn't open automatically: {url}") 55 | webbrowser.open(url) 56 | 57 | server = SpotifyAPI._AuthorizationServer("127.0.0.1", SpotifyAPI._SERVER_PORT) 58 | try: 59 | while True: 60 | server.handle_request() 61 | except SpotifyAPI._Authorization as auth: 62 | return SpotifyAPI(auth.access_token) 63 | 64 | @staticmethod 65 | def _construct_auth_url(client_id, scope, redirect_uri): 66 | return "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode( 67 | { 68 | "response_type": "token", 69 | "client_id": client_id, 70 | "scope": scope, 71 | "redirect_uri": redirect_uri, 72 | } 73 | ) 74 | 75 | def _construct_url(self, url, params): 76 | """Construct a full API URL.""" 77 | if not url.startswith(self.BASE_URL): 78 | url = self.BASE_URL + url 79 | if params: 80 | url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params) 81 | return url 82 | 83 | def _create_request(self, url): 84 | """Create an authenticated request.""" 85 | req = urllib.request.Request(url) 86 | req.add_header("Authorization", f"Bearer {self._auth}") 87 | return req 88 | 89 | def _read_response(self, req): 90 | """Read and parse the response.""" 91 | with urllib.request.urlopen(req) as res: 92 | reader = codecs.getreader("utf-8") 93 | return json.load(reader(res)) 94 | 95 | _SERVER_PORT = 43019 96 | 97 | class _AuthorizationServer(http.server.HTTPServer): 98 | def __init__(self, host, port): 99 | super().__init__((host, port), SpotifyAPI._AuthorizationHandler) 100 | 101 | def handle_error(self, request, client_address): 102 | raise 103 | 104 | class _AuthorizationHandler(http.server.BaseHTTPRequestHandler): 105 | def do_GET(self): 106 | if self.path.startswith("/redirect"): 107 | self._redirect_to_token() 108 | elif self.path.startswith("/token?"): 109 | self._handle_token() 110 | else: 111 | self.send_error(404) 112 | 113 | def _redirect_to_token(self): 114 | self.send_response(200) 115 | self.send_header("Content-Type", "text/html") 116 | self.end_headers() 117 | self.wfile.write( 118 | b'<script>location.replace("token?" + location.hash.slice(1));</script>' 119 | ) 120 | 121 | def _handle_token(self): 122 | self.send_response(200) 123 | self.send_header("Content-Type", "text/html") 124 | self.end_headers() 125 | self.wfile.write( 126 | b"<script>close()</script>Thanks! You may now close this window." 127 | ) 128 | access_token = re.search("access_token=([^&]*)", self.path).group(1) 129 | raise SpotifyAPI._Authorization(access_token) 130 | 131 | def log_message(self, format, *args): 132 | pass 133 | 134 | class _Authorization(Exception): 135 | def __init__(self, access_token): 136 | self.access_token = access_token 137 | 138 | 139 | def fetch_user_data(spotify, dump): 140 | """Fetch playlists and liked songs based on the dump parameter.""" 141 | playlists = [] 142 | liked_albums = [] 143 | 144 | if "liked" in dump: 145 | print("Loading liked albums and songs...") 146 | liked_tracks = spotify.list("me/tracks", {"limit": 50}) 147 | liked_albums = spotify.list("me/albums", {"limit": 50}) 148 | playlists.append({"name": "Liked Songs", "tracks": liked_tracks}) 149 | 150 | if "playlists" in dump: 151 | print("Loading playlists...") 152 | playlist_data = spotify.list("me/playlists", {"limit": 50}) 153 | for playlist in playlist_data: 154 | print(f"Loading playlist: {playlist['name']}") 155 | playlist["tracks"] = spotify.list( 156 | playlist["tracks"]["href"], {"limit": 100} 157 | ) 158 | playlists.extend(playlist_data) 159 | 160 | return playlists, liked_albums 161 | 162 | 163 | def write_to_file(file, format, playlists, liked_albums): 164 | """Write fetched data to a file in the specified format.""" 165 | print(f"Writing to {file}...") 166 | with open(file, "w", encoding="utf-8") as f: 167 | if format == "json": 168 | json.dump({"playlists": playlists, "albums": liked_albums}, f) 169 | else: 170 | for playlist in playlists: 171 | f.write(playlist["name"] + "\r\n") 172 | for track in playlist["tracks"]: 173 | if track["track"]: 174 | f.write( 175 | "{name}\t{artists}\t{album}\t{uri}\t{release_date}\r\n".format( 176 | uri=track["track"]["uri"], 177 | name=track["track"]["name"], 178 | artists=", ".join( 179 | [ 180 | artist["name"] 181 | for artist in track["track"]["artists"] 182 | ] 183 | ), 184 | album=track["track"]["album"]["name"], 185 | release_date=track["track"]["album"]["release_date"], 186 | ) 187 | ) 188 | f.write("\r\n") 189 | 190 | 191 | def main(dump="playlists,liked", format="json", file="playlists.json", token=""): 192 | print("Starting backup...") 193 | spotify = ( 194 | SpotifyAPI(token) 195 | if token 196 | else SpotifyAPI.authorize( 197 | client_id="5c098bcc800e45d49e476265bc9b6934", 198 | scope="playlist-read-private playlist-read-collaborative user-library-read", 199 | ) 200 | ) 201 | 202 | playlists, liked_albums = fetch_user_data(spotify, dump) 203 | write_to_file(file, format, playlists, liked_albums) 204 | print(f"Backup completed! Data written to {file}") 205 | 206 | 207 | if __name__ == "__main__": 208 | main() 209 | -------------------------------------------------------------------------------- /spotify2ytmusic/ytmusic_credentials.py: -------------------------------------------------------------------------------- 1 | import ytmusicapi 2 | 3 | import os 4 | 5 | 6 | def setup_ytmusic_with_raw_headers( 7 | input_file="raw_headers.txt", credentials_file="oauth.json" 8 | ): 9 | """ 10 | Loads raw headers from a file and sets up YTMusic connection using ytmusicapi.setup. 11 | 12 | Parameters: 13 | input_file (str): Path to the file containing raw headers. 14 | credentials_file (str): Path to save the configuration headers (credentials). 15 | 16 | Returns: 17 | str: Configuration headers string returned by ytmusicapi.setup. 18 | """ 19 | # Check if the input file exists 20 | if not os.path.exists(input_file): 21 | raise FileNotFoundError(f"Input file {input_file} does not exist.") 22 | 23 | # Read the raw headers from the file 24 | with open(input_file, "r") as file: 25 | headers_raw = file.read() 26 | 27 | # Use ytmusicapi.setup to process headers and save the credentials 28 | config_headers = ytmusicapi.setup( 29 | filepath=credentials_file, headers_raw=headers_raw 30 | ) 31 | print(f"Configuration headers saved to {credentials_file}") 32 | return config_headers 33 | 34 | 35 | if __name__ == "__main__": 36 | try: 37 | # Specify file paths 38 | raw_headers_file = "raw_headers.txt" 39 | credentials_file = "oauth.json" 40 | 41 | # Set up YTMusic with raw headers 42 | print(f"Setting up YTMusic using headers from {raw_headers_file}...") 43 | setup_ytmusic_with_raw_headers( 44 | input_file=raw_headers_file, credentials_file=credentials_file 45 | ) 46 | 47 | print("YTMusic setup completed successfully!") 48 | 49 | except Exception as e: 50 | print(f"An error occurred: {e}") 51 | -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import unittest 4 | from unittest.mock import patch, MagicMock 5 | import spotify2ytmusic 6 | 7 | 8 | class TestCopier(unittest.TestCase): 9 | @patch("spotify2ytmusic.cli.YTMusic") 10 | def test_copier_success(self, mock_ytmusic): 11 | # Setup mock responses 12 | mock_ytmusic_instance = MagicMock() 13 | mock_ytmusic.return_value = mock_ytmusic_instance 14 | mock_ytmusic_instance.get_playlist.return_value = {"title": "Test Playlist"} 15 | mock_ytmusic_instance.add_playlist_items.return_value = None 16 | 17 | spotify2ytmusic.cli.copier( 18 | spotify2ytmusic.cli.iter_spotify_playlist( 19 | "68QlHDwCiXfhodLpS72iOx", 20 | spotify_playlist_file="tests/playliststest.json", 21 | ), 22 | dst_pl_id="dst_test", 23 | ) 24 | 25 | mock_ytmusic_instance.get_playlist.assert_called_once_with( 26 | playlistId="dst_test" 27 | ) 28 | 29 | @patch("spotify2ytmusic.cli.YTMusic") 30 | def test_copier_albums(self, mock_ytmusic): 31 | # Setup mock responses 32 | mock_ytmusic_instance = MagicMock() 33 | mock_ytmusic.return_value = mock_ytmusic_instance 34 | mock_ytmusic_instance.get_playlist.return_value = {"title": "Test Playlist"} 35 | mock_ytmusic_instance.add_playlist_items.return_value = None 36 | 37 | spotify2ytmusic.cli.copier( 38 | spotify2ytmusic.cli.iter_spotify_liked_albums( 39 | spotify_playlist_file="tests/playliststest.json" 40 | ), 41 | dst_pl_id="dst_test", 42 | ) 43 | 44 | mock_ytmusic_instance.get_playlist.assert_called_once_with( 45 | playlistId="dst_test" 46 | ) 47 | 48 | @patch("spotify2ytmusic.cli.YTMusic") 49 | def test_copier_liked_playlists(self, mock_ytmusic): 50 | # Setup mock responses 51 | mock_ytmusic_instance = MagicMock() 52 | mock_ytmusic.return_value = mock_ytmusic_instance 53 | mock_ytmusic_instance.get_playlist.return_value = {"title": "Test Playlist"} 54 | mock_ytmusic_instance.add_playlist_items.return_value = None 55 | 56 | spotify2ytmusic.cli.copier( 57 | spotify2ytmusic.cli.iter_spotify_playlist( 58 | None, spotify_playlist_file="tests/playliststest.json" 59 | ), 60 | dst_pl_id="dst_test", 61 | track_sleep=0, 62 | ) 63 | 64 | mock_ytmusic_instance.get_playlist.assert_called_once_with( 65 | playlistId="dst_test" 66 | ) 67 | 68 | 69 | if __name__ == "__main__": 70 | unittest.main() 71 | --------------------------------------------------------------------------------