The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | 


--------------------------------------------------------------------------------