├── raw_headers.txt ├── requirements.txt ├── S2YM.gif ├── s2ym.png ├── .gitignore ├── LICENSE ├── S2YM.sh ├── S2YM.bat ├── readme.md ├── copy_playlists.py └── ui.py /raw_headers.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | spotipy 2 | ytmusicapi 3 | tqdm 4 | -------------------------------------------------------------------------------- /S2YM.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahdi-y/Spotify2YoutubeMusic/HEAD/S2YM.gif -------------------------------------------------------------------------------- /s2ym.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahdi-y/Spotify2YoutubeMusic/HEAD/s2ym.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual environment 2 | venv/ 3 | env/ 4 | ENV/ 5 | .venv/ 6 | .venv 7 | .git/ 8 | .git 9 | 10 | # Python cache files 11 | __pycache__/ 12 | *.pyc 13 | *.pyo 14 | *.pyd 15 | 16 | # IDE/editor-specific files 17 | .vscode/ 18 | .idea/ 19 | *.sublime-project 20 | *.sublime-workspace 21 | 22 | # OS-specific files 23 | .DS_Store 24 | Thumbs.db 25 | 26 | # Distribution / packaging 27 | build/ 28 | develop-eggs/ 29 | dist/ 30 | downloads/ 31 | eggs/ 32 | .eggs/ 33 | lib/ 34 | lib64/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | MANIFEST 42 | .cache 43 | 44 | # Logs and databases 45 | *.log 46 | *.sqlite3 47 | 48 | # Project related files 49 | browser.json 50 | config.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mahdi-y 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /S2YM.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Spotify2YoutubeMusic Auto-Setup and Launcher (macOS/Linux) 4 | 5 | STARTDIR="$PWD" 6 | 7 | echo "========================================" 8 | echo " Spotify2YoutubeMusic Auto-Setup" 9 | echo "========================================" 10 | echo 11 | 12 | # Check Python 3.8+ is installed 13 | if ! command -v python3 &> /dev/null; then 14 | echo "[ERROR] Python 3.8+ is not installed or not in PATH!" 15 | echo "Please install Python 3.8+ from https://python.org" 16 | exit 1 17 | fi 18 | 19 | PYVER=$(python3 --version 2>&1 | awk '{print $2}') 20 | MAJOR=$(echo "$PYVER" | cut -d. -f1) 21 | MINOR=$(echo "$PYVER" | cut -d. -f2) 22 | 23 | if [ "$MAJOR" -lt 3 ] || { [ "$MAJOR" -eq 3 ] && [ "$MINOR" -lt 8 ]; }; then 24 | echo "[ERROR] Python 3.8+ is required. Detected: $PYVER" 25 | exit 1 26 | fi 27 | 28 | echo "[OK] Python found: $PYVER" 29 | 30 | # Check Git 31 | if ! command -v git &> /dev/null; then 32 | echo "[ERROR] Git is not installed or not in PATH!" 33 | echo "Install Git with: sudo apt install git OR brew install git" 34 | exit 1 35 | fi 36 | 37 | echo "[OK] Git found: $(git --version)" 38 | echo 39 | 40 | # Clone or update repo 41 | if [ -d "Spotify2YoutubeMusic" ]; then 42 | echo "[INFO] Found existing Spotify2YoutubeMusic directory" 43 | echo "[UPDATE] Pulling latest changes..." 44 | cd Spotify2YoutubeMusic || exit 1 45 | git pull origin master || echo "[WARN] Git pull failed, continuing with existing files..." 46 | else 47 | echo "[DOWNLOAD] Cloning repository..." 48 | git clone https://github.com/mahdi-y/Spotify2YoutubeMusic.git || { echo "[ERROR] Failed to clone repository!"; exit 1; } 49 | echo "[OK] Repository cloned successfully" 50 | cd Spotify2YoutubeMusic || exit 1 51 | fi 52 | 53 | echo 54 | echo "[SETUP] Setting up Python virtual environment..." 55 | 56 | # Create venv if needed 57 | if [ ! -d ".venv" ]; then 58 | echo "Creating virtual environment..." 59 | python3 -m venv .venv || { echo "[ERROR] Failed to create virtual environment!"; cd "$STARTDIR"; exit 1; } 60 | echo "[OK] Virtual environment created" 61 | else 62 | echo "[OK] Virtual environment already exists" 63 | fi 64 | 65 | # Activate venv 66 | echo "Activating virtual environment..." 67 | # shellcheck disable=SC1091 68 | source .venv/bin/activate || { echo "[ERROR] Failed to activate virtual environment!"; cd "$STARTDIR"; exit 1; } 69 | echo "[OK] Virtual environment activated" 70 | echo 71 | 72 | # Check for requirements.txt 73 | if [ ! -f requirements.txt ]; then 74 | echo "[ERROR] requirements.txt not found!" 75 | cd "$STARTDIR" 76 | exit 1 77 | fi 78 | 79 | # Install dependencies 80 | echo "[INSTALL] Installing/updating dependencies..." 81 | python3 -m pip install --upgrade pip 82 | pip install -r requirements.txt 83 | 84 | if [ $? -ne 0 ]; then 85 | echo "[ERROR] Failed to install some dependencies! Trying to continue anyway..." 86 | else 87 | echo "[OK] All dependencies installed successfully" 88 | fi 89 | 90 | echo 91 | echo "[LAUNCH] Starting Spotify2YoutubeMusic..." 92 | echo "========================================" 93 | echo " Setup Complete! Starting App..." 94 | echo "========================================" 95 | echo 96 | 97 | python3 ui.py 98 | 99 | echo 100 | echo "[DONE] Thanks for using Spotify2YoutubeMusic!" 101 | echo "You can re-run this script anytime to update and launch the app." 102 | read -n 1 -s -r -p "Press any key to exit..." 103 | 104 | cd "$STARTDIR" -------------------------------------------------------------------------------- /S2YM.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | title Spotify2YoutubeMusic Auto-Setup and Launcher 3 | color 07 4 | 5 | setlocal enabledelayedexpansion 6 | 7 | REM Store the starting directory 8 | set STARTDIR=%CD% 9 | 10 | echo ======================================== 11 | echo Spotify2YoutubeMusic Auto-Setup 12 | echo ======================================== 13 | echo. 14 | 15 | REM Check if Python is installed 16 | python --version >nul 2>&1 17 | if errorlevel 1 ( 18 | echo [ERROR] Python is not installed or not in PATH! 19 | echo Please install Python 3.8+ from https://python.org 20 | echo Make sure to check "Add Python to PATH" during installation 21 | pause 22 | exit /b 1 23 | ) 24 | 25 | REM Check Python version (must be 3.8+) 26 | for /f "tokens=2 delims= " %%v in ('python --version') do set PYVER=%%v 27 | for /f "tokens=1,2 delims=." %%a in ("!PYVER!") do ( 28 | set MAJOR=%%a 29 | set MINOR=%%b 30 | ) 31 | if !MAJOR! LSS 3 ( 32 | echo [ERROR] Python 3.8+ is required. Detected: !PYVER! 33 | pause 34 | exit /b 1 35 | ) 36 | if !MAJOR! EQU 3 if !MINOR! LSS 8 ( 37 | echo [ERROR] Python 3.8+ is required. Detected: !PYVER! 38 | pause 39 | exit /b 1 40 | ) 41 | 42 | echo [OK] Python found: !PYVER! 43 | 44 | REM Check if Git is installed 45 | git --version >nul 2>&1 46 | if errorlevel 1 ( 47 | echo [ERROR] Git is not installed or not in PATH! 48 | echo. 49 | echo Two options: 50 | echo 1. Install Git from https://git-scm.com/download/win 51 | echo 2. Or download the ZIP file manually from GitHub 52 | pause 53 | exit /b 1 54 | ) 55 | 56 | echo [OK] Git found 57 | git --version 58 | echo. 59 | 60 | REM Check if directory exists 61 | if exist "Spotify2YoutubeMusic" ( 62 | echo [INFO] Found existing Spotify2YoutubeMusic directory 63 | echo [UPDATE] Pulling latest changes... 64 | cd Spotify2YoutubeMusic 65 | git pull origin master 66 | if errorlevel 1 ( 67 | echo [WARN] Git pull failed, continuing with existing files... 68 | ) else ( 69 | echo [OK] Successfully updated to latest version 70 | ) 71 | ) else ( 72 | echo [DOWNLOAD] Cloning repository... 73 | git clone https://github.com/mahdi-y/Spotify2YoutubeMusic.git 74 | if errorlevel 1 ( 75 | echo [ERROR] Failed to clone repository! 76 | echo Please check your internet connection or download manually 77 | pause 78 | exit /b 1 79 | ) 80 | echo [OK] Repository cloned successfully 81 | cd Spotify2YoutubeMusic 82 | ) 83 | 84 | echo. 85 | echo [SETUP] Setting up Python virtual environment... 86 | 87 | REM Create virtual environment if it doesn't exist 88 | if not exist ".venv" ( 89 | echo Creating virtual environment... 90 | python -m venv .venv 91 | if errorlevel 1 ( 92 | echo [ERROR] Failed to create virtual environment! 93 | cd /d "%STARTDIR%" 94 | pause 95 | exit /b 1 96 | ) 97 | echo [OK] Virtual environment created 98 | ) else ( 99 | echo [OK] Virtual environment already exists 100 | ) 101 | 102 | REM Activate virtual environment 103 | echo Activating virtual environment... 104 | call .venv\Scripts\activate.bat 105 | if errorlevel 1 ( 106 | echo [ERROR] Failed to activate virtual environment! 107 | cd /d "%STARTDIR%" 108 | pause 109 | exit /b 1 110 | ) 111 | 112 | echo [OK] Virtual environment activated 113 | echo. 114 | 115 | REM Check for requirements.txt 116 | if not exist requirements.txt ( 117 | echo [ERROR] requirements.txt not found! 118 | echo Please make sure you are in the correct directory. 119 | cd /d "%STARTDIR%" 120 | pause 121 | exit /b 1 122 | ) 123 | 124 | REM Install/upgrade dependencies 125 | echo [INSTALL] Installing/updating dependencies... 126 | python -m pip install --upgrade pip 127 | pip install -r requirements.txt 128 | 129 | if errorlevel 1 ( 130 | echo [ERROR] Failed to install some dependencies! 131 | echo Trying to continue anyway... 132 | ) else ( 133 | echo [OK] All dependencies installed successfully 134 | ) 135 | 136 | echo. 137 | echo [LAUNCH] Starting Spotify2YoutubeMusic... 138 | echo. 139 | echo ======================================== 140 | echo Setup Complete! Starting App... 141 | echo ======================================== 142 | echo. 143 | 144 | REM Start the application 145 | python ui.py 146 | 147 | echo. 148 | echo [DONE] Thanks for using Spotify2YoutubeMusic! 149 | echo You can re-run this file anytime to update and launch the app. 150 | echo Press any key to exit... 151 | pause >nul 152 | 153 | REM Return to original directory 154 | cd /d "%STARTDIR%" 155 | endlocal -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Spotify ➡️ YouTube Music Playlist Copier 2 | 3 |

4 | Python 5 | Spotify 6 | YouTube Music 7 | License 8 | Stars 9 |

10 | 11 |

12 | 13 |

14 | 15 | --- 16 | 17 |

18 | Spotify2YoutubeMusic Logo 19 | 20 |

21 | 22 | ### ✨ New: Easy Setup Scripts 23 | 24 | - **S2YM.bat** (Windows) and **S2YM.sh** (macOS/Linux) now provide a one-step setup and launch experience. 25 | - No need to manually clone the repo or install dependencies—just run the script! 26 | - The scripts check for Python 3.8+, Git, set up a virtual environment, install all requirements, and launch the app. 27 | - ASCII art and clear progress messages make the setup process friendlier and more visual. 28 | 29 | --- 30 | 31 | ## Table of Contents 32 | 33 | 1. [Introduction](#introduction) 34 | 2. [✨ What's New](#-whats-new) 35 | 3. [Features](#features) 36 | 4. [Requirements](#requirements) 37 | 5. [🚀 Quick Start (Recommended)](#-quick-start-recommended) 38 | 6. [Installation (Manual)](#installation-manual) 39 | 7. [Usage](#usage) 40 | 8. [User Interface](#user-interface) 41 | 9. [Troubleshooting](#troubleshooting) 42 | 10. [File Structure](#file-structure) 43 | 11. [Contributing](#contributing) 44 | 12. [Acknowledgments](#acknowledgments) 45 | 13. [License](#license) 46 | 47 | --- 48 | 49 | ## Introduction 50 | 51 | **Spotify2YoutubeMusic** is a powerful Python tool that seamlessly transfers your music library from Spotify to YouTube Music. It features both a modern graphical interface and a command-line interface, making it easy to copy playlists, liked songs, and followed artists between platforms. 52 | 53 | --- 54 | 55 | ## ✨ What's New 56 | 57 | ### **Batch Size Control (NEW)** 58 | - **Adjustable Batch Size:** You can now set how many tracks are processed together (1–20) using a slider in the main interface. 59 | - **Quick Presets:** Instantly set Safe, Balanced, Fast, or Max batch sizes with one click. 60 | - **Live Guidance:** Color-coded descriptions help you pick the best setting for your connection and reliability needs. 61 | 62 | ### **Smart Resume System** 63 | - **Automatic Header Expiration Detection:** Detects when YouTube Music headers expire and pauses gracefully. 64 | - **Progress Saving:** Automatically saves progress after each batch, not just on errors. 65 | - **Seamless Resume:** After updating expired headers, transfers resume exactly where they left off at the batch level. 66 | 67 | ### **Real-time Progress Tracking** 68 | - **Batch-level Progress:** Shows detailed progress for each batch being processed and verified. 69 | - **Live Status Updates:** Real-time feedback on search, add, and verification operations. 70 | - **Success Rate Reporting:** Shows exact transfer success rates and detailed logs of any failed tracks. 71 | 72 | ### **Improved Verification** 73 | - **Whole Playlist Verification:** After any interruption, the app verifies all tracks that should be in the playlist, not just those added after resuming, for accurate success reporting. 74 | 75 | ### **API Management** 76 | - **Dedicated Quota Testing:** New "Test API Quota" functionality to check quotas without running transfers. 77 | - **Intelligent Quota Detection:** Correctly distinguishes between real quota exhaustion and YouTube Music backend delays. 78 | - **Header Validation:** Real-time validation of YouTube Music headers before saving. 79 | 80 | ### **Improved Transfer Process** 81 | - **Not Found Track Display:** All tracks that couldn't be found on YouTube Music are now displayed in the UI logs. 82 | - **Backend Delay Handling:** Properly handles YouTube Music's playlist count delays (no more false quota warnings). 83 | - **Optimized Timing:** Smart 3-second delays between batches to balance speed and reliability. 84 | 85 | --- 86 | 87 | ## Features 88 | 89 | - **Playlist Transfer** - Copy individual or all playlists from Spotify to YouTube Music 90 | - **Liked Songs Import** - Transfer all your Spotify liked songs to a dedicated playlist 91 | - **Artist Following** - Subscribe to your followed Spotify artists on YouTube Music 92 | - **Smart Duplicate Prevention** - Automatically detects existing playlists and only adds new songs 93 | - **Incremental Updates** - Run multiple times without creating duplicates 94 | - **Real-time Progress Tracking** - Visual progress bars and detailed status updates 95 | - **Modern GUI Interface** - Beautiful, dark-themed graphical user interface 96 | - **Cross-platform** - Works on Windows, Linux, and macOS 97 | - **Resume Capability** - Automatically resume interrupted transfers 98 | - **Header Expiration Detection** - Smart handling of expired YouTube Music headers 99 | - **Batch Size Control** - Fine-tune reliability and speed for large playlists (NEW) 100 | 101 | --- 102 | 103 | ## Requirements 104 | 105 | - **Python 3.8+** 106 | - **Spotify API Credentials** (Client ID & Secret) 107 | - **YouTube Music Browser Headers** (for authentication) 108 | - **Internet Connection** (for API access) 109 | 110 | ### Required Python Packages: 111 | - `spotipy` - Spotify Web API wrapper 112 | - `ytmusicapi` - YouTube Music API wrapper 113 | - `tqdm` - Progress bars 114 | - `tkinter` - GUI framework (usually included with Python) 115 | 116 | --- 117 | 118 | ## 🚀 Quick Start (Recommended) 119 | 120 | Skip the manual installation! Use these one-liner commands to automatically download, set up, and launch the app: 121 | 122 | #### **Windows (PowerShell):** 123 | ```powershell 124 | iwr -useb https://raw.githubusercontent.com/mahdi-y/Spotify2YoutubeMusic/master/S2YM.bat -OutFile S2YM.bat; ./S2YM.bat 125 | ``` 126 | 127 | #### **macOS/Linux:** 128 | ```bash 129 | curl -O https://raw.githubusercontent.com/mahdi-y/Spotify2YoutubeMusic/master/S2YM.sh && bash S2YM.sh 130 | ``` 131 | 132 | **What these commands do:** 133 | - Download the setup script for your platform 134 | - Check for Python 3.8+ and Git 135 | - Clone or update the repository 136 | - Set up a virtual environment 137 | - Install all dependencies 138 | - Launch the app automatically 139 | 140 | **How to run:** 141 | - **Windows:** Open PowerShell, paste the command, and press Enter 142 | - **macOS/Linux:** Open Terminal, paste the command, and press Enter 143 | 144 | --- 145 | 146 | ## Installation (Manual) 147 | 148 | ### 1. Clone the Repository 149 | 150 | ```bash 151 | git clone https://github.com/mahdi-y/Spotify2YoutubeMusic.git 152 | cd Spotify2YoutubeMusic 153 | ``` 154 | 155 | ### 2. Set Up Virtual Environment (Recommended) 156 | 157 | #### Windows: 158 | ```bash 159 | python -m venv .venv 160 | .venv\Scripts\activate 161 | ``` 162 | 163 | #### Linux/macOS: 164 | ```bash 165 | python3 -m venv .venv 166 | source .venv/bin/activate 167 | ``` 168 | 169 | ### 3. Install Dependencies 170 | 171 | ```bash 172 | pip install -r requirements.txt 173 | ``` 174 | 175 | --- 176 | 177 | ## Usage 178 | 179 | ### Quick Start 180 | 181 | 1. **Get Spotify API Credentials** 182 | 2. **Extract YouTube Music Headers** 183 | 3. **Run the Application** 184 | 185 | ### 1. Generate Spotify Credentials 186 | 187 | 1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) 188 | 2. Create a new app 189 | 3. Note your **Client ID** and **Client Secret** 190 | 4. Set **Redirect URI** to: `http://127.0.0.1:8888/callback` 191 | 5. Enter credentials in the Settings dialog when prompted 192 | 193 | ### 2. Generate YouTube Music Headers 194 | 195 | 1. Open **Firefox** and navigate to [YouTube Music](https://music.youtube.com) 196 | 2. Log in to your account 197 | 3. Press **F12** to open Developer Tools 198 | 4. Go to **Network** tab 199 | 5. Click **Library** in YouTube Music 200 | 6. Filter by `/browse` and find a **POST** request `(You have to type /browse in the search bar)` 201 | 7. Right-click → Copy → Copy Request Headers 202 | 8. Paste headers in the Settings dialog 203 | 204 | ### 3. Run the Application 205 | 206 | #### Graphical Interface (Recommended): 207 | ```bash 208 | python ui.py 209 | ``` 210 | 211 | --- 212 | 213 | ## User Interface 214 | 215 | ### Modern GUI Features 216 | 217 | - **Dark Theme** - Easy on the eyes with modern styling 218 | - **Tabbed Interface** - Organized sections for different transfer types 219 | - **Real-time Progress** - Live progress bars and status updates 220 | - **Output Logging** - Detailed transfer logs with clear indicators 221 | - **Playlist Selection** - Choose specific playlists or transfer all at once 222 | - **Settings Management** - Built-in credentials and headers management with validation 223 | - **Batch Size Control** - Instantly adjust batch size with a slider and quick preset buttons (NEW) 224 | 225 | ### Batch Size Control (NEW) 226 | - **Adjustable Batch Size:** Choose how many tracks are processed together (1–20) using a slider in the main interface. 227 | - **Quick Presets:** Instantly set Safe, Balanced, Fast, or Max batch sizes with one click. 228 | - **Live Guidance:** Color-coded descriptions help you pick the best setting for your connection and reliability needs. 229 | 230 | ### Playlists Tab 231 | - Load and view all your Spotify playlists 232 | - Select multiple playlists for transfer 233 | - One-click "Copy All" functionality 234 | - Real-time search progress with track names 235 | - **Resume Support:** Automatically resumes interrupted transfers 236 | 237 | ### Liked Songs Tab 238 | - Transfer all liked songs to YouTube Music 239 | - Creates a dedicated "Liked Songs from Spotify" playlist 240 | - Handles large libraries efficiently 241 | - **Smart Batching:** Processes in batches for better reliability 242 | 243 | ### Artists Tab 244 | - Subscribe to followed Spotify artists 245 | - Batch processing for multiple artists 246 | - Automatic matching and subscription 247 | 248 | ### Settings Dialog 249 | - **Spotify Configuration:** Enter Client ID, Secret, and Redirect URI 250 | - **YouTube Music Headers:** Paste raw browser headers with real-time validation 251 | - **Header Testing:** Built-in header validation before saving 252 | - **Built-in Instructions:** Step-by-step guides for getting credentials 253 | 254 | --- 255 | 256 | ## Troubleshooting 257 | 258 | ### Common Issues 259 | 260 | **Header Expiration (New Handling)** 261 | - Headers typically expire every 20-30 minutes 262 | - The app now automatically detects expiration and pauses gracefully 263 | - Progress is saved at the batch level, so you resume exactly where you left off 264 | - Simply update headers in Settings and the transfer continues automatically 265 | 266 | **Transfer Interruptions (Enhanced)** 267 | - Resume functionality works at the batch level for maximum efficiency 268 | - Check the `progress_*.json` files to see saved state 269 | 270 | **Missing Tracks (Improved Display)** 271 | - Missing tracks are now clearly displayed in the UI output log 272 | - Each playlist shows exactly which tracks couldn't be found 273 | - Some tracks may not be available on YouTube Music due to licensing 274 | - Alternative versions might be found instead 275 | 276 | **Backend Delays (No More False Warnings)** 277 | - YouTube Music sometimes delays updating playlist track counts 278 | - The app now correctly identifies this as a backend delay, not quota exhaustion 279 | - Wait a few minutes (sometimes it may take a while) and check your playlist - the tracks should appear 280 | - No longer shows misleading "quota exhaustion" messages for this issue 281 | 282 | **Authentication Problems** 283 | - **Spotify:** Verify Client ID, Secret, and Redirect URI in Settings 284 | - **YouTube Music:** Re-extract headers if they expire or validation fails 285 | - **Settings now validate headers in real-time before saving** 286 | - Delete the `.cache` file if you get Spotify authentication errors 287 | 288 | ### New Features for Debugging 289 | 290 | 1. **Enhanced Logging:** More detailed output shows exactly what's happening at each step 291 | 2. **Progress Files:** Check `progress_*.json` files to see exactly where transfers stopped 292 | 3. **Header Validation:** Settings dialog now validates headers before saving 293 | 294 | --- 295 | 296 | ## File Structure 297 | 298 | ``` 299 | Spotify2YoutubeMusic/ 300 | ├── copy_playlists.py # Main script with CLI interface 301 | ├── ui.py # Modern GUI application 302 | ├── config.json # Configuration file (auto-generated) 303 | ├── progress_*.json # Progress files for resume functionality (auto-generated) 304 | ├── browser.json # YouTube Music API config (auto-generated) 305 | ├── requirements.txt # Python dependencies 306 | ├── S2YM.bat # Windows auto-setup & launcher script (NEW) 307 | ├── S2YM.sh # macOS/Linux auto-setup & launcher script (NEW) 308 | ├── README.md # This file 309 | └── LICENSE # MIT License 310 | ``` 311 | 312 | --- 313 | 314 | ## Contributing 315 | 316 | We welcome contributions! Please feel free to: 317 | - Report bugs and issues 318 | - Suggest new features 319 | - Submit pull requests 320 | - Improve documentation 321 | 322 | --- 323 | 324 | ## Acknowledgments 325 | 326 | - **[sigma67](https://github.com/sigma67/ytmusicapi)** - Creator of ytmusicapi 327 | - **[Spotipy Team](https://spotipy.readthedocs.io/)** - Spotify Web API wrapper 328 | - **Community Contributors** - Bug reports and feature suggestions 329 | 330 | --- 331 | 332 | ## License 333 | 334 | This project is licensed under the [MIT License](LICENSE). 335 | 336 | ``` 337 | MIT License - Feel free to use, modify, and distribute 338 | See LICENSE file for full details 339 | ``` 340 | 341 | --- 342 | 343 | ## Disclaimer 344 | 345 | This tool is for personal use only. Please respect the terms of service of both Spotify and YouTube Music. The developers are not responsible for any violations of these services' terms of use. 346 | 347 | --- 348 | 349 |
350 | 351 | ### **Enjoy Your Music Everywhere!** 352 | 353 | *Transfer your music library seamlessly between platforms with smart resume, verification, and progress tracking* 354 | 355 | **Latest: Batch size control • Automatic resume on header expiration • Batch verification • Real-time progress tracking • Enhanced reliability • Easy setup scripts** 356 | 357 |
358 | -------------------------------------------------------------------------------- /copy_playlists.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | from spotipy.oauth2 import SpotifyOAuth 3 | from ytmusicapi import YTMusic 4 | import time 5 | from ytmusicapi import setup 6 | from tqdm import tqdm 7 | import json 8 | import os 9 | 10 | sp = None 11 | ytmusic = None 12 | 13 | def load_config(): 14 | if os.path.exists("config.json"): 15 | try: 16 | with open("config.json", "r") as f: 17 | return json.load(f) 18 | except: 19 | pass 20 | return None 21 | 22 | def validate_youtube_headers(headers_text): 23 | if not headers_text or not headers_text.strip(): 24 | return False, "Headers are empty" 25 | 26 | headers = headers_text.strip() 27 | 28 | required_fields = ['cookie', 'user-agent'] 29 | headers_lower = headers.lower() 30 | 31 | missing_fields = [] 32 | for field in required_fields: 33 | if field not in headers_lower: 34 | missing_fields.append(field) 35 | 36 | if missing_fields: 37 | return False, f"Missing required header fields: {', '.join(missing_fields)}" 38 | 39 | lines = headers.split('\n') 40 | valid_lines = 0 41 | for line in lines: 42 | line = line.strip() 43 | if line and ':' in line: 44 | valid_lines += 1 45 | 46 | if valid_lines < 5: 47 | return False, "Headers don't appear to be in the correct format" 48 | 49 | return True, "Headers appear valid" 50 | 51 | def initialize_clients(config_data=None): 52 | global sp, ytmusic 53 | 54 | if config_data is None: 55 | config_data = load_config() 56 | 57 | if not config_data: 58 | print("No configuration found. Please run the UI to set up credentials.") 59 | return False 60 | 61 | try: 62 | sp_oauth = SpotifyOAuth( 63 | client_id=config_data["spotify_client_id"], 64 | client_secret=config_data["spotify_client_secret"], 65 | redirect_uri=config_data["spotify_redirect_uri"], 66 | scope="playlist-read-private playlist-read-collaborative user-library-read user-follow-read" 67 | ) 68 | sp = spotipy.Spotify(auth_manager=sp_oauth) 69 | 70 | if config_data.get("youtube_headers"): 71 | try: 72 | headers = config_data["youtube_headers"].strip() 73 | 74 | if not headers: 75 | print("YouTube Music headers are empty") 76 | ytmusic = None 77 | return False 78 | 79 | required_fields = ['cookie', 'user-agent'] 80 | headers_lower = headers.lower() 81 | if not any(field in headers_lower for field in required_fields): 82 | print("YouTube Music headers appear to be invalid - missing required fields") 83 | ytmusic = None 84 | return False 85 | 86 | with open("raw_headers.txt", "w", encoding="utf-8") as f: 87 | f.write(headers) 88 | 89 | try: 90 | setup(filepath="browser.json", headers_raw=headers) 91 | except Exception as setup_error: 92 | print(f"Failed to parse headers: {setup_error}") 93 | print("Please check that your headers are in the correct format") 94 | ytmusic = None 95 | return False 96 | 97 | ytmusic = YTMusic("browser.json") 98 | 99 | try: 100 | test_result = ytmusic.get_library_playlists(limit=1) 101 | if test_result is None: 102 | print("YouTube Music client test failed - headers may be expired") 103 | ytmusic = None 104 | return False 105 | except Exception as test_error: 106 | print(f"YouTube Music connection test failed: {test_error}") 107 | ytmusic = None 108 | return False 109 | 110 | except Exception as e: 111 | print(f"Failed to initialize YouTube Music client: {e}") 112 | ytmusic = None 113 | return False 114 | else: 115 | print("No YouTube Music headers provided") 116 | return False 117 | 118 | return True 119 | 120 | except Exception as e: 121 | print(f"Error initializing clients: {e}") 122 | return False 123 | 124 | def get_spotify_client(): 125 | global sp 126 | if sp is None: 127 | initialize_clients() 128 | return sp 129 | 130 | def get_ytmusic_client(): 131 | global ytmusic 132 | if ytmusic is None: 133 | if not initialize_clients(): 134 | return None 135 | return ytmusic 136 | 137 | initialize_clients() 138 | 139 | def get_ytm_playlist_by_name(playlist_name): 140 | try: 141 | playlists = get_ytmusic_client().get_library_playlists() 142 | for playlist in playlists: 143 | if playlist['title'].strip().lower() == playlist_name.strip().lower(): 144 | return playlist 145 | return None 146 | except Exception as e: 147 | print(f"Error fetching YouTube Music playlists: {e}") 148 | return None 149 | 150 | def get_ytm_playlist_song_video_ids(playlist_id): 151 | video_ids = set() 152 | try: 153 | playlist = get_ytmusic_client().get_playlist(playlist_id, limit=10000) 154 | for track in playlist.get('tracks', []): 155 | if track and 'videoId' in track: 156 | video_ids.add(track['videoId']) 157 | except Exception as e: 158 | print(f"Error fetching playlist tracks: {e}") 159 | return video_ids 160 | 161 | def create_or_get_ytm_playlist(playlist_name): 162 | existing = get_ytm_playlist_by_name(playlist_name) 163 | if existing: 164 | print(f"Found existing YouTube Music playlist: {playlist_name} (ID: {existing['playlistId']})") 165 | return existing['playlistId'], True 166 | else: 167 | playlist_id = create_ytm_playlist(playlist_name) 168 | return playlist_id, False 169 | 170 | def list_spotify_playlists(): 171 | playlists = [] 172 | limit = 50 173 | offset = 0 174 | 175 | while True: 176 | results = get_spotify_client().current_user_playlists(limit=limit, offset=offset) 177 | if not results['items']: 178 | break 179 | 180 | for idx, playlist in enumerate(results['items'], start=len(playlists) + 1): 181 | print(f"{idx}. {playlist['name']} (ID: {playlist['id']})") 182 | playlists.append(playlist) 183 | 184 | offset += limit 185 | 186 | if not playlists: 187 | print("No playlists found!") 188 | 189 | return playlists 190 | 191 | def get_spotify_liked_songs(): 192 | liked_songs = [] 193 | results = get_spotify_client().current_user_saved_tracks() 194 | while results: 195 | for item in results['items']: 196 | track = item['track'] 197 | if track: 198 | artist_name = track['artists'][0]['name'] 199 | track_name = track['name'] 200 | liked_songs.append(f"{artist_name} - {track_name}") 201 | if results['next']: 202 | results = get_spotify_client().next(results) 203 | else: 204 | break 205 | return liked_songs 206 | 207 | def get_spotify_playlist_tracks(playlist_id): 208 | tracks = [] 209 | results = get_spotify_client().playlist_items(playlist_id) 210 | while results: 211 | for item in results['items']: 212 | track = item['track'] 213 | if track: 214 | artist_name = track['artists'][0]['name'] 215 | track_name = track['name'] 216 | tracks.append(f"{artist_name} - {track_name}") 217 | if results['next']: 218 | results = get_spotify_client().next(results) 219 | else: 220 | break 221 | return tracks 222 | 223 | def create_ytm_playlist(playlist_name): 224 | try: 225 | playlist_id = get_ytmusic_client().create_playlist(title=playlist_name, description="Copied from Spotify") 226 | print(f"Created YouTube Music playlist: {playlist_name} (ID: {playlist_id})") 227 | return playlist_id 228 | except Exception as e: 229 | print(f"Failed to create playlist: {playlist_name}") 230 | print(e) 231 | return None 232 | 233 | def search_track_on_ytm(track_query): 234 | try: 235 | search_results = get_ytmusic_client().search(query=track_query, filter="songs") 236 | if search_results: 237 | video_id = search_results[0]['videoId'] 238 | return video_id 239 | else: 240 | return None 241 | except Exception as e: 242 | print(f"Error searching for track: {track_query}") 243 | print(e) 244 | return None 245 | 246 | def test_ytmusic_connection(): 247 | try: 248 | ytmusic = get_ytmusic_client() 249 | ytmusic.get_library_playlists(limit=1) 250 | return True 251 | except Exception as e: 252 | if "401" in str(e) or "403" in str(e) or "unauthorized" in str(e).lower(): 253 | return False 254 | return True 255 | 256 | def save_progress(playlist_name, current_track_index, total_tracks, ytm_video_ids, not_found_tracks, operation_type="playlist", current_batch_index=0): 257 | progress_data = { 258 | "playlist_name": playlist_name, 259 | "current_track_index": current_track_index, 260 | "total_tracks": total_tracks, 261 | "ytm_video_ids": ytm_video_ids, 262 | "not_found_tracks": not_found_tracks, 263 | "operation_type": operation_type, 264 | "current_batch_index": current_batch_index, 265 | "timestamp": time.time() 266 | } 267 | filename = f"progress_{playlist_name.replace(' ', '_').replace('/', '_')}.json" 268 | with open(filename, "w", encoding="utf-8") as f: 269 | json.dump(progress_data, f, indent=2) 270 | return filename 271 | 272 | def load_progress(playlist_name): 273 | try: 274 | filename = f"progress_{playlist_name.replace(' ', '_').replace('/', '_')}.json" 275 | if os.path.exists(filename): 276 | with open(filename, "r", encoding="utf-8") as f: 277 | return json.load(f) 278 | except Exception as e: 279 | print(f"Error loading progress: {e}") 280 | return None 281 | 282 | def delete_progress(playlist_name): 283 | try: 284 | filename = f"progress_{playlist_name.replace(' ', '_').replace('/', '_')}.json" 285 | if os.path.exists(filename): 286 | os.remove(filename) 287 | except: 288 | pass 289 | 290 | class HeaderExpiredError(Exception): 291 | def __init__(self, message, batch_index=None): 292 | super().__init__(message) 293 | self.batch_index = batch_index 294 | 295 | def add_tracks_to_ytm_playlist_with_header_check( 296 | playlist_id, track_ids, batch_size=10, retry_attempts=3, batch_delay=3, start_batch_index=0, progress_callback=None 297 | ): 298 | try: 299 | total_batches = (len(track_ids) + batch_size - 1) // batch_size 300 | for batch_num, i in enumerate(range(0, len(track_ids), batch_size)): 301 | if batch_num < start_batch_index: 302 | continue 303 | 304 | batch = track_ids[i:i + batch_size] 305 | attempt = 0 306 | while attempt < retry_attempts: 307 | try: 308 | if not test_ytmusic_connection(): 309 | raise HeaderExpiredError("Headers expired", batch_index=batch_num) 310 | get_ytmusic_client().add_playlist_items(playlistId=playlist_id, videoIds=batch) 311 | print(f"Added tracks {i + 1} to {i + len(batch)} to YouTube Music playlist.") 312 | break 313 | except Exception as e: 314 | error_str = str(e).lower() 315 | if "401" in error_str or "403" in error_str or "unauthorized" in error_str: 316 | print("Detected expired headers during batch add.") 317 | raise HeaderExpiredError("Headers expired during batch add", batch_index=batch_num) 318 | if "HTTP 409" in str(e): 319 | print(f"Conflict error for tracks {i + 1} to {i + len(batch)}. Skipping...") 320 | break 321 | else: 322 | attempt += 1 323 | print(f"Attempt {attempt} failed for tracks {i + 1} to {i + len(batch)}. Retrying in 5 seconds...") 324 | time.sleep(5) 325 | else: 326 | print(f"Failed to add tracks {i + 1} to {i + len(batch)} after {retry_attempts} attempts.") 327 | 328 | if progress_callback: 329 | progress_callback(i + len(batch)) 330 | 331 | time.sleep(batch_delay) 332 | 333 | except HeaderExpiredError as e: 334 | raise e 335 | except Exception as e: 336 | print(f"Failed to add tracks to playlist ID: {playlist_id}") 337 | print(e) 338 | 339 | def add_tracks_to_ytm_playlist(playlist_id, track_ids, batch_size=10, retry_attempts=3): 340 | try: 341 | for i in range(0, len(track_ids), batch_size): 342 | batch = track_ids[i:i + batch_size] 343 | attempt = 0 344 | while attempt < retry_attempts: 345 | try: 346 | get_ytmusic_client().add_playlist_items(playlistId=playlist_id, videoIds=batch) 347 | print(f"Added tracks {i + 1} to {i + len(batch)} to YouTube Music playlist.") 348 | break 349 | except Exception as e: 350 | if "HTTP 409" in str(e): 351 | print(f"Conflict error for tracks {i + 1} to {i + len(batch)}. Skipping...") 352 | break 353 | else: 354 | attempt += 1 355 | print(f"Attempt {attempt} failed for tracks {i + 1} to {i + len(batch)}. Retrying in 5 seconds...") 356 | time.sleep(5) 357 | else: 358 | print(f"Failed to add tracks {i + 1} to {i + len(batch)} after {retry_attempts} attempts.") 359 | 360 | time.sleep(2) 361 | 362 | except Exception as e: 363 | print(f"Failed to add tracks to playlist ID: {playlist_id}") 364 | print(e) 365 | 366 | def get_spotify_followed_artists(): 367 | followed_artists = [] 368 | results = get_spotify_client().current_user_followed_artists(limit=50) 369 | while results: 370 | for artist in results['artists']['items']: 371 | followed_artists.append(artist['name']) 372 | if results['artists']['next']: 373 | results = get_spotify_client().next(results['artists']) 374 | else: 375 | break 376 | return followed_artists 377 | 378 | def subscribe_to_ytm_artists(artist_names): 379 | for artist_name in artist_names: 380 | try: 381 | search_results = get_ytmusic_client().search(query=artist_name, filter="artists") 382 | if search_results: 383 | artist_id = search_results[0]['browseId'] 384 | get_ytmusic_client().subscribe_artists([artist_id]) 385 | print(f"Subscribed to artist: {artist_name} (ID: {artist_id})") 386 | else: 387 | print(f"No results found for artist: {artist_name}") 388 | except Exception as e: 389 | print(f"Failed to subscribe to artist: {artist_name}") 390 | print(e) 391 | 392 | def parse_playlist_selection(selection_input, max_playlists): 393 | selected_indices = set() 394 | 395 | parts = [p.strip() for p in selection_input.split(",")] 396 | 397 | for part in parts: 398 | if "-" in part: 399 | try: 400 | start, end = map(int, part.split("-")) 401 | if start < 1 or end > max_playlists or start > end: 402 | print(f"Invalid range: {part}. Valid range is 1-{max_playlists}.") 403 | continue 404 | for i in range(start-1, end): 405 | selected_indices.add(i) 406 | except ValueError: 407 | print(f"Invalid range format: {part}") 408 | else: 409 | try: 410 | idx = int(part) - 1 411 | if idx < 0 or idx >= max_playlists: 412 | print(f"Invalid playlist number: {part}") 413 | continue 414 | selected_indices.add(idx) 415 | except ValueError: 416 | print(f"Invalid input: {part}") 417 | 418 | return sorted(list(selected_indices)) 419 | 420 | def copy_spotify_to_ytm(): 421 | if not perform_quota_check(): 422 | print("\n⚠️ Cannot proceed due to API quota/connection issues.") 423 | print("Please try again later or check your credentials.") 424 | return 425 | 426 | while True: 427 | choice = input("Do you want to copy (1) Playlists, (2) Liked Songs, or (3) Followed Artists from Spotify? Enter 1, 2, or 3 (or type 'exit' to quit): ") 428 | if choice.lower() == 'exit': 429 | print("Exiting...") 430 | break 431 | 432 | if choice == "1": 433 | spotify_playlists = list_spotify_playlists() 434 | if not spotify_playlists: 435 | return 436 | copy_all = input("Do you want to copy all playlists? (yes/no): ").strip().lower() 437 | if copy_all == 'yes': 438 | for playlist in spotify_playlists: 439 | playlist_name = playlist['name'] 440 | playlist_id = playlist['id'] 441 | print(f"Fetching tracks from Spotify playlist: {playlist_name}") 442 | spotify_tracks = get_spotify_playlist_tracks(playlist_id) 443 | if not spotify_tracks: 444 | print(f"No tracks found in the playlist: {playlist_name}. Skipping this playlist.") 445 | continue 446 | 447 | original_playlist_name = playlist_name 448 | playlist_name = playlist_name[:150] 449 | 450 | ytm_playlist_id, already_exists = create_or_get_ytm_playlist(playlist_name) 451 | if not ytm_playlist_id: 452 | continue 453 | 454 | existing_video_ids = set() 455 | if already_exists: 456 | print("Checking for already existing songs in the YouTube Music playlist...") 457 | existing_video_ids = get_ytm_playlist_song_video_ids(ytm_playlist_id) 458 | 459 | print(f"Searching for tracks from {playlist_name} on YouTube Music...") 460 | ytm_video_ids = [] 461 | not_found_tracks = [] 462 | 463 | progress = load_progress(playlist_name) 464 | if progress: 465 | print(f"📁 Found saved progress for '{playlist_name}'. Resuming...") 466 | start_index = progress["current_track_index"] 467 | ytm_video_ids = progress["ytm_video_ids"] 468 | not_found_tracks = progress["not_found_tracks"] 469 | current_batch_index = progress.get("current_batch_index", 0) 470 | else: 471 | start_index = 0 472 | current_batch_index = 0 473 | 474 | try: 475 | for idx in range(start_index, len(spotify_tracks)): 476 | track = spotify_tracks[idx] 477 | video_id = search_track_on_ytm(track) 478 | if video_id: 479 | if video_id not in existing_video_ids: 480 | ytm_video_ids.append(video_id) 481 | else: 482 | not_found_tracks.append(track) 483 | print(f"Skipping track: {track}") 484 | 485 | if ytm_video_ids: 486 | try: 487 | print(f"Adding {len(ytm_video_ids)} tracks to YouTube Music...") 488 | add_tracks_to_ytm_playlist_with_header_check( 489 | ytm_playlist_id, 490 | ytm_video_ids, 491 | start_batch_index=current_batch_index 492 | ) 493 | 494 | if detect_quota_exhaustion(ytm_playlist_id, ytm_video_ids): 495 | print("\n⚠️ DAILY API QUOTA EXCEEDED!") 496 | print("The playlist transfer appears successful but no tracks were actually added.") 497 | print("Please wait 24 hours for your quota to reset and try again.") 498 | return 499 | else: 500 | print(f"✅ Successfully added {len(ytm_video_ids)} tracks to: {playlist_name}") 501 | 502 | except HeaderExpiredError: 503 | progress_file = save_progress(playlist_name, len(spotify_tracks), len(spotify_tracks), 504 | ytm_video_ids, not_found_tracks, "playlist") 505 | print(f"\n🔑 YouTube Music headers have expired!") 506 | print(f"💾 Progress saved to: {progress_file}") 507 | print("Please update your headers using the UI and run the script again.") 508 | print("The script will automatically resume from where it left off.") 509 | return 510 | else: 511 | print(f"No new tracks to add for playlist: {playlist_name}") 512 | 513 | delete_progress(playlist_name) 514 | 515 | except HeaderExpiredError: 516 | progress_file = save_progress(playlist_name, idx, len(spotify_tracks), 517 | ytm_video_ids, not_found_tracks, "playlist") 518 | print(f"\n🔑 YouTube Music headers have expired!") 519 | print(f"💾 Progress saved to: {progress_file}") 520 | print("Please update your headers using the UI and run the script again.") 521 | print("The script will automatically resume from where it left off.") 522 | return 523 | 524 | elif choice == "2": 525 | liked_songs = get_spotify_liked_songs() 526 | if not liked_songs: 527 | print("No liked songs found on Spotify.") 528 | return 529 | playlist_name = "Liked Songs from Spotify" 530 | 531 | ytm_playlist_id, already_exists = create_or_get_ytm_playlist(playlist_name) 532 | if not ytm_playlist_id: 533 | return 534 | 535 | existing_video_ids = set() 536 | if already_exists: 537 | print("Checking for already existing songs in the YouTube Music playlist...") 538 | existing_video_ids = get_ytm_playlist_song_video_ids(ytm_playlist_id) 539 | 540 | print("Searching for liked songs on YouTube Music...") 541 | ytm_video_ids = [] 542 | not_found_tracks = [] 543 | for track in tqdm(liked_songs, desc="Processing Liked Songs", unit="track"): 544 | video_id = search_track_on_ytm(track) 545 | if video_id: 546 | if video_id not in existing_video_ids: 547 | ytm_video_ids.append(video_id) 548 | else: 549 | not_found_tracks.append(track) 550 | print(f"Skipping track: {track}") 551 | 552 | if ytm_video_ids: 553 | print(f"\nAdding {len(ytm_video_ids)} liked songs to YouTube Music with verification...") 554 | add_tracks_to_ytm_playlist_with_verification(ytm_playlist_id, ytm_video_ids) 555 | else: 556 | print("No new liked songs to add to YouTube Music.") 557 | 558 | if not_found_tracks: 559 | print(f"\nLiked songs not found on YouTube Music:") 560 | for track in not_found_tracks: 561 | print(f"- {track}") 562 | print() 563 | 564 | elif choice == "3": 565 | followed_artists = get_spotify_followed_artists() 566 | if not followed_artists: 567 | print("No followed artists found on Spotify.") 568 | return 569 | print("Subscribing to artists on YouTube Music...") 570 | subscribe_to_ytm_artists(followed_artists) 571 | print("Finished subscribing to artists.") 572 | 573 | def verify_batch_added(playlist_id, expected_video_ids, max_retries=3): 574 | for attempt in range(max_retries): 575 | try: 576 | current_playlist_ids = get_ytm_playlist_song_video_ids(playlist_id) 577 | missing_ids = [vid for vid in expected_video_ids if vid not in current_playlist_ids] 578 | 579 | if not missing_ids: 580 | return True, [] 581 | else: 582 | print(f"Verification attempt {attempt + 1}: {len(missing_ids)} tracks missing") 583 | if attempt < max_retries - 1: 584 | time.sleep(3) 585 | 586 | except Exception as e: 587 | print(f"Verification attempt {attempt + 1} failed: {e}") 588 | if attempt < max_retries - 1: 589 | time.sleep(3) 590 | 591 | try: 592 | current_playlist_ids = get_ytm_playlist_song_video_ids(playlist_id) 593 | missing_ids = [vid for vid in expected_video_ids if vid not in current_playlist_ids] 594 | return len(missing_ids) == 0, missing_ids 595 | except: 596 | return False, expected_video_ids 597 | 598 | def add_tracks_to_ytm_playlist_with_verification(playlist_id, track_ids, batch_size=10, retry_attempts=3): 599 | if not track_ids: 600 | return 601 | 602 | total_added = 0 603 | failed_tracks = [] 604 | 605 | try: 606 | for i in range(0, len(track_ids), batch_size): 607 | batch = track_ids[i:i + batch_size] 608 | batch_attempt = 0 609 | current_batch = batch.copy() 610 | 611 | while batch_attempt < retry_attempts and current_batch: 612 | try: 613 | if not test_ytmusic_connection(): 614 | raise HeaderExpiredError("Headers expired") 615 | 616 | print(f"Adding batch {i//batch_size + 1}: {len(current_batch)} tracks (attempt {batch_attempt + 1})") 617 | get_ytmusic_client().add_playlist_items(playlistId=playlist_id, videoIds=current_batch) 618 | 619 | time.sleep(3) 620 | 621 | verification_success, missing_ids = verify_batch_added(playlist_id, current_batch) 622 | 623 | if verification_success: 624 | print(f"✅ Batch {i//batch_size + 1}: All {len(current_batch)} tracks added successfully") 625 | total_added += len(current_batch) 626 | break 627 | else: 628 | added_count = len(current_batch) - len(missing_ids) 629 | total_added += added_count 630 | print(f"⚠️ Batch {i//batch_size + 1}: {added_count}/{len(current_batch)} tracks added, {len(missing_ids)} missing") 631 | 632 | if batch_attempt < retry_attempts - 1: 633 | current_batch = missing_ids 634 | print(f"🔄 Retrying {len(missing_ids)} missing tracks...") 635 | time.sleep(5) 636 | else: 637 | print(f"❌ Failed to add {len(missing_ids)} tracks after {retry_attempts} attempts") 638 | failed_tracks.extend(missing_ids) 639 | 640 | except HeaderExpiredError: 641 | raise 642 | except Exception as e: 643 | if "HTTP 409" in str(e): 644 | print(f"Conflict error for batch {i//batch_size + 1}. Checking which tracks were added...") 645 | verification_success, missing_ids = verify_batch_added(playlist_id, current_batch) 646 | added_count = len(current_batch) - len(missing_ids) 647 | total_added += added_count 648 | if missing_ids and batch_attempt < retry_attempts - 1: 649 | current_batch = missing_ids 650 | print(f"🔄 Retrying {len(missing_ids)} tracks that weren't added...") 651 | else: 652 | failed_tracks.extend(missing_ids) 653 | break 654 | else: 655 | batch_attempt += 1 656 | if batch_attempt < retry_attempts: 657 | print(f"Batch attempt {batch_attempt} failed: {e}. Retrying in 5 seconds...") 658 | time.sleep(5) 659 | else: 660 | print(f"❌ Batch failed after {retry_attempts} attempts: {e}") 661 | failed_tracks.extend(current_batch) 662 | 663 | batch_attempt += 1 664 | 665 | time.sleep(2) 666 | 667 | print(f"\n📊 Final Results:") 668 | print(f" • Successfully added: {total_added}/{len(track_ids)} tracks") 669 | print(f" • Failed to add: {len(failed_tracks)} tracks") 670 | 671 | if failed_tracks: 672 | print(f" • Success rate: {(total_added/len(track_ids)*100):.1f}%") 673 | else: 674 | print(f" • Success rate: 100%") 675 | 676 | except HeaderExpiredError: 677 | raise 678 | except Exception as e: 679 | print(f"Failed to add tracks to playlist ID: {playlist_id}") 680 | print(e) 681 | 682 | def check_api_quota(): 683 | try: 684 | ytmusic = get_ytmusic_client() 685 | if ytmusic is None: 686 | return False, "YouTube Music client not initialized - check headers" 687 | 688 | test_playlist_id = ytmusic.create_playlist( 689 | title="API_QUOTA_TEST_DELETE_ME", 690 | description="Testing API quota - will be deleted" 691 | ) 692 | if not test_playlist_id: 693 | return False, "Failed to create test playlist (quota or headers issue)" 694 | 695 | test_video_id = 'lYBUbBu4W08' 696 | try: 697 | ytmusic.add_playlist_items(test_playlist_id, [test_video_id]) 698 | except Exception as e: 699 | try: ytmusic.delete_playlist(test_playlist_id) 700 | except: pass 701 | return False, f"Failed to add test song: {e}" 702 | 703 | time.sleep(2) 704 | playlist = ytmusic.get_playlist(test_playlist_id) 705 | found = False 706 | for track in playlist.get('tracks', []): 707 | if track.get('videoId') == test_video_id: 708 | found = True 709 | break 710 | 711 | track_count = playlist.get('trackCount', 0) 712 | try: ytmusic.delete_playlist(test_playlist_id) 713 | except: pass 714 | 715 | if found and track_count > 0: 716 | return True, "API quota available (playlist create/add/verify succeeded, track count correct)" 717 | elif found and track_count == 0: 718 | return False, "⚠️ Song is present in playlist but track count is 0 (YouTube Music cache delay or API quirk). Try again in a few minutes, or proceed if you see this only in the test." 719 | else: 720 | return False, "Test song not found in playlist after add - quota likely exhausted or headers invalid" 721 | 722 | except Exception as e: 723 | return False, f"API quota check failed: {e}" 724 | 725 | def check_spotify_quota(): 726 | try: 727 | sp = get_spotify_client() 728 | 729 | user_info = sp.current_user() 730 | 731 | if user_info: 732 | return True, "Spotify API available" 733 | else: 734 | return False, "Spotify API connection failed" 735 | 736 | except Exception as e: 737 | error_str = str(e).lower() 738 | 739 | if "429" in error_str or "rate limit" in error_str: 740 | return False, f"Spotify rate limit exceeded: {e}" 741 | elif "401" in error_str or "unauthorized" in error_str: 742 | return False, f"Spotify authentication failed - check credentials: {e}" 743 | elif "403" in error_str or "forbidden" in error_str: 744 | return False, f"Spotify access denied - check permissions: {e}" 745 | else: 746 | return False, f"Spotify API error: {e}" 747 | 748 | def verify_playlist_actually_updated(playlist_id, expected_minimum_tracks): 749 | try: 750 | time.sleep(3) 751 | 752 | actual_tracks = get_ytm_playlist_song_video_ids(playlist_id) 753 | actual_count = len(actual_tracks) 754 | 755 | print(f"🔍 Verification: Expected at least {expected_minimum_tracks}, found {actual_count}") 756 | 757 | if actual_count >= expected_minimum_tracks: 758 | return True, actual_count 759 | else: 760 | return False, actual_count 761 | 762 | except Exception as e: 763 | print(f"❌ Verification failed: {e}") 764 | return False, 0 765 | 766 | def detect_quota_exhaustion(playlist_id, video_ids_added): 767 | try: 768 | success, actual_count = verify_playlist_actually_updated(playlist_id, len(video_ids_added)) 769 | 770 | if actual_count == 0 and len(video_ids_added) > 0: 771 | print("⚠️ Playlist appears empty, but this is likely a YouTube Music backend delay.") 772 | print(" - All tracks were added, but the playlist count is not updated yet.") 773 | print(" - Please wait a few minutes and check again in YouTube Music.") 774 | print(" - This is NOT a quota exhaustion issue.") 775 | return False 776 | elif actual_count < len(video_ids_added): 777 | print("⚠️ Partial success: Some tracks were not added. This may be due to API delays or silent drops, not necessarily quota exhaustion.") 778 | print(f" - Expected: {len(video_ids_added)} tracks") 779 | print(f" - Actual: {actual_count} tracks") 780 | return False 781 | return False 782 | 783 | except Exception as e: 784 | print(f"Error detecting quota exhaustion: {e}") 785 | return False 786 | 787 | def perform_quota_check(): 788 | print("🔍 Checking API quotas...") 789 | 790 | spotify_ok, spotify_msg = check_spotify_quota() 791 | if spotify_ok: 792 | print(f"✅ Spotify: {spotify_msg}") 793 | else: 794 | print(f"❌ Spotify: {spotify_msg}") 795 | return False, f"Spotify: {spotify_msg}" 796 | 797 | ytm_ok, ytm_msg = check_api_quota() 798 | if ytm_ok: 799 | print(f"✅ YouTube Music: {ytm_msg}") 800 | return True, f"Spotify: {spotify_msg}\nYouTube Music: {ytm_msg}" 801 | else: 802 | print(f"❌ YouTube Music: {ytm_msg}") 803 | return False, f"Spotify: {spotify_msg}\nYouTube Music: {ytm_msg}" 804 | 805 | def add_tracks_with_delayed_verification( 806 | playlist_id, track_ids, batch_size=5, retry_attempts=3, 807 | batch_delay=5, verification_delay=30, progress_callback=None, 808 | start_batch_index=0 809 | ): 810 | 811 | successfully_added = [] 812 | failed_batches = [] 813 | 814 | try: 815 | total_batches = (len(track_ids) + batch_size - 1) // batch_size 816 | 817 | for i in range(start_batch_index * batch_size, len(track_ids), batch_size): 818 | batch = track_ids[i:i + batch_size] 819 | batch_num = (i // batch_size) + 1 820 | current_batch_index = i // batch_size 821 | 822 | attempt = 0 823 | while attempt < retry_attempts: 824 | try: 825 | if not test_ytmusic_connection(): 826 | raise HeaderExpiredError("Headers expired", batch_index=current_batch_index) 827 | 828 | print(f"Adding batch {batch_num}/{total_batches}: {len(batch)} tracks") 829 | get_ytmusic_client().add_playlist_items(playlistId=playlist_id, videoIds=batch) 830 | successfully_added.extend(batch) 831 | break 832 | 833 | except Exception as e: 834 | error_str = str(e).lower() 835 | if "401" in error_str or "403" in error_str or "unauthorized" in error_str: 836 | raise HeaderExpiredError("Headers expired", batch_index=current_batch_index) 837 | elif "HTTP 409" in str(e): 838 | print(f"Conflict error for batch {batch_num}. Assuming success...") 839 | successfully_added.extend(batch) 840 | break 841 | else: 842 | attempt += 1 843 | print(f"Batch {batch_num} attempt {attempt} failed: {e}") 844 | if attempt < retry_attempts: 845 | time.sleep(batch_delay * attempt) 846 | else: 847 | print(f"❌ Batch {batch_num} failed after all attempts") 848 | failed_batches.append(batch) 849 | 850 | if progress_callback: 851 | progress_callback(len(successfully_added)) 852 | 853 | time.sleep(batch_delay) 854 | 855 | if start_batch_index > 0: 856 | try: 857 | progress_file = f"progress_{playlist_id.replace(' ', '_').replace('/', '_')}.json" 858 | if os.path.exists(progress_file): 859 | with open(progress_file, "r", encoding="utf-8") as f: 860 | progress_data = json.load(f) 861 | all_track_ids = progress_data.get("ytm_video_ids", track_ids) 862 | else: 863 | all_track_ids = track_ids 864 | except Exception: 865 | all_track_ids = track_ids 866 | else: 867 | all_track_ids = track_ids.copy() 868 | 869 | if successfully_added or start_batch_index > 0: 870 | print(f"\n⏳ Waiting {verification_delay}s before final verification...") 871 | time.sleep(verification_delay) 872 | 873 | print("🔍 Performing final verification...") 874 | final_tracks = get_ytm_playlist_song_video_ids(playlist_id) 875 | actually_added = [vid for vid in all_track_ids if vid in final_tracks] 876 | 877 | print(f"📊 Final Results:") 878 | print(f" Attempted: {len(all_track_ids)} tracks") 879 | print(f" Verified: {len(actually_added)} tracks") 880 | print(f" Missing: {len(all_track_ids) - len(actually_added)} tracks") 881 | print(f" Success Rate: {(len(actually_added)/len(all_track_ids)*100):.1f}%") 882 | 883 | return actually_added, failed_batches 884 | 885 | return successfully_added, failed_batches 886 | 887 | 888 | except HeaderExpiredError as e: 889 | raise e 890 | except Exception as e: 891 | print(f"Error in delayed verification method: {e}") 892 | return successfully_added, failed_batches 893 | 894 | if __name__ == "__main__": 895 | copy_spotify_to_ytm() -------------------------------------------------------------------------------- /ui.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import messagebox, ttk 3 | import threading 4 | import copy_playlists 5 | import json 6 | import os 7 | 8 | def load_config(): 9 | if os.path.exists("config.json"): 10 | try: 11 | with open("config.json", "r") as f: 12 | return json.load(f) 13 | except: 14 | pass 15 | return { 16 | "spotify_client_id": "", 17 | "spotify_client_secret": "", 18 | "spotify_redirect_uri": "http://127.0.0.1:8888/callback", 19 | "youtube_headers": "" 20 | } 21 | 22 | def save_config(config): 23 | try: 24 | with open("config.json", "w") as f: 25 | json.dump(config, f, indent=2) 26 | return True 27 | except Exception as e: 28 | print(f"Error saving config: {e}") 29 | return False 30 | 31 | class SettingsDialog: 32 | def __init__(self, parent, config_data, callback): 33 | self.parent = parent 34 | self.callback = callback 35 | self.config_data = config_data.copy() 36 | 37 | self.dialog = tk.Toplevel(parent) 38 | self.dialog.title("Settings - Credentials & Headers") 39 | self.dialog.geometry("600x700") 40 | self.dialog.configure(bg='#1e1e1e') 41 | self.dialog.resizable(False, False) 42 | self.dialog.grab_set() 43 | 44 | self.dialog.transient(parent) 45 | self.dialog.geometry("+%d+%d" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50)) 46 | 47 | self.create_widgets() 48 | 49 | def create_widgets(self): 50 | main_frame = tk.Frame(self.dialog, bg='#1e1e1e') 51 | main_frame.pack(fill="both", expand=True, padx=20, pady=20) 52 | 53 | title_label = tk.Label(main_frame, 54 | text="⚙️ Settings", 55 | font=('Segoe UI', 16, 'bold'), 56 | fg='white', 57 | bg='#1e1e1e') 58 | title_label.pack(pady=(0, 20)) 59 | 60 | spotify_frame = tk.LabelFrame(main_frame, 61 | text="🎵 Spotify Configuration", 62 | bg='#2d2d2d', 63 | fg='white', 64 | font=('Segoe UI', 11, 'bold')) 65 | spotify_frame.pack(fill="x", pady=(0, 15)) 66 | 67 | tk.Label(spotify_frame, text="Client ID:", bg='#2d2d2d', fg='white', font=('Segoe UI', 10)).pack(anchor="w", padx=10, pady=(10, 5)) 68 | self.client_id_entry = tk.Entry(spotify_frame, width=70, bg='#404040', fg='white', font=('Consolas', 9)) 69 | self.client_id_entry.pack(fill="x", padx=10, pady=(0, 10)) 70 | self.client_id_entry.insert(0, self.config_data.get("spotify_client_id", "")) 71 | 72 | tk.Label(spotify_frame, text="Client Secret:", bg='#2d2d2d', fg='white', font=('Segoe UI', 10)).pack(anchor="w", padx=10, pady=(5, 5)) 73 | self.client_secret_entry = tk.Entry(spotify_frame, width=70, show="*", bg='#404040', fg='white', font=('Consolas', 9)) 74 | self.client_secret_entry.pack(fill="x", padx=10, pady=(0, 10)) 75 | self.client_secret_entry.insert(0, self.config_data.get("spotify_client_secret", "")) 76 | 77 | tk.Label(spotify_frame, text="Redirect URI:", bg='#2d2d2d', fg='white', font=('Segoe UI', 10)).pack(anchor="w", padx=10, pady=(5, 5)) 78 | self.redirect_uri_entry = tk.Entry(spotify_frame, width=70, bg='#404040', fg='white', font=('Consolas', 9)) 79 | self.redirect_uri_entry.pack(fill="x", padx=10, pady=(0, 10)) 80 | self.redirect_uri_entry.insert(0, self.config_data.get("spotify_redirect_uri", "http://127.0.0.1:8888/callback")) 81 | 82 | instructions_btn = tk.Button(spotify_frame, 83 | text="📖 How to get Spotify credentials", 84 | command=self.show_spotify_instructions, 85 | bg='#0078d4', fg='white', 86 | font=('Segoe UI', 9)) 87 | instructions_btn.pack(pady=5) 88 | 89 | youtube_frame = tk.LabelFrame(main_frame, 90 | text="🎼 YouTube Music Headers", 91 | bg='#2d2d2d', 92 | fg='white', 93 | font=('Segoe UI', 11, 'bold')) 94 | youtube_frame.pack(fill="both", expand=True, pady=(0, 15)) 95 | 96 | tk.Label(youtube_frame, text="Raw Headers:", bg='#2d2d2d', fg='white', font=('Segoe UI', 10)).pack(anchor="w", padx=10, pady=(10, 5)) 97 | 98 | headers_frame = tk.Frame(youtube_frame, bg='#2d2d2d') 99 | headers_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) 100 | 101 | self.headers_text = tk.Text(headers_frame, 102 | height=8, 103 | wrap="word", 104 | bg='#404040', 105 | fg='white', 106 | font=('Consolas', 8)) 107 | headers_scrollbar = tk.Scrollbar(headers_frame, orient="vertical", command=self.headers_text.yview) 108 | self.headers_text.configure(yscrollcommand=headers_scrollbar.set) 109 | 110 | self.headers_text.pack(side="left", fill="both", expand=True) 111 | headers_scrollbar.pack(side="right", fill="y") 112 | 113 | self.headers_text.insert("1.0", self.config_data.get("youtube_headers", "")) 114 | 115 | youtube_instructions_btn = tk.Button(youtube_frame, 116 | text="📖 How to get YouTube Music headers", 117 | command=self.show_youtube_instructions, 118 | bg='#ff6b6b', fg='white', 119 | font=('Segoe UI', 9)) 120 | youtube_instructions_btn.pack(pady=5) 121 | 122 | button_frame = tk.Frame(main_frame, bg='#1e1e1e') 123 | button_frame.pack(fill="x", pady=(10, 0)) 124 | 125 | save_btn = tk.Button(button_frame, 126 | text="💾 Save Configuration", 127 | command=self.save_config, 128 | bg='#107c10', fg='white', 129 | font=('Segoe UI', 10, 'bold')) 130 | save_btn.pack(side="left", padx=(0, 10)) 131 | 132 | cancel_btn = tk.Button(button_frame, 133 | text="❌ Cancel", 134 | command=self.dialog.destroy, 135 | bg='#d13438', fg='white', 136 | font=('Segoe UI', 10)) 137 | cancel_btn.pack(side="right") 138 | 139 | def update_delay_description(self, value): 140 | delay = int(float(value)) 141 | 142 | self.current_value_label.config(text=f"Current: {delay}s") 143 | 144 | if delay <= 3: 145 | description = "⚡ Fast (2-3s): Quick transfer but higher chance of missing tracks due to YouTube Music rate limits." 146 | color = '#ff6b6b' 147 | elif delay <= 6: 148 | description = "🚀 Moderate (4-6s): Balanced speed and reliability. Some tracks might still be missed occasionally." 149 | color = '#ffd93d' 150 | elif delay <= 10: 151 | description = "✅ Recommended (7-10s): Good balance of speed and completeness. Most reliable for most playlists." 152 | color = '#6bcf7f' 153 | elif delay <= 15: 154 | description = "🐌 Slow (11-15s): Very reliable but slower transfer. Best for large playlists or unstable connections." 155 | color = '#4ecdc4' 156 | else: 157 | description = "🕰️ Very Slow (16s+): Maximum reliability but very slow. Use only if experiencing frequent issues." 158 | color = '#a8dadc' 159 | 160 | self.delay_description.config(text=description, fg=color) 161 | 162 | def save_config(self): 163 | self.config_data["spotify_client_id"] = self.client_id_entry.get().strip() 164 | self.config_data["spotify_client_secret"] = self.client_secret_entry.get().strip() 165 | self.config_data["spotify_redirect_uri"] = self.redirect_uri_entry.get().strip() 166 | self.config_data["youtube_headers"] = self.headers_text.get("1.0", tk.END).strip() 167 | 168 | if not self.config_data["spotify_client_id"]: 169 | messagebox.showerror("Error", "Spotify Client ID is required!") 170 | return 171 | if not self.config_data["spotify_client_secret"]: 172 | messagebox.showerror("Error", "Spotify Client Secret is required!") 173 | return 174 | if not self.config_data["youtube_headers"]: 175 | messagebox.showerror("Error", "YouTube Music headers are required!") 176 | return 177 | 178 | headers_valid, validation_msg = copy_playlists.validate_youtube_headers(self.config_data["youtube_headers"]) 179 | if not headers_valid: 180 | messagebox.showerror("Invalid Headers", f"YouTube Music headers are invalid:\n{validation_msg}") 181 | return 182 | 183 | try: 184 | from ytmusicapi import setup, YTMusic 185 | 186 | with open("test_headers.txt", "w", encoding="utf-8") as f: 187 | f.write(self.config_data["youtube_headers"]) 188 | 189 | setup(filepath="test_browser.json", headers_raw=self.config_data["youtube_headers"]) 190 | test_ytmusic = YTMusic("test_browser.json") 191 | 192 | test_result = test_ytmusic.get_library_playlists(limit=1) 193 | 194 | try: 195 | os.remove("test_headers.txt") 196 | os.remove("test_browser.json") 197 | except: 198 | pass 199 | 200 | if test_result is None: 201 | messagebox.showerror("Invalid Headers", 202 | "Headers test failed!\n" 203 | "The headers appear to be expired or invalid.\n" 204 | "Please get fresh headers from YouTube Music.") 205 | return 206 | 207 | except Exception as e: 208 | try: 209 | os.remove("test_headers.txt") 210 | os.remove("test_browser.json") 211 | except: 212 | pass 213 | messagebox.showerror("Headers Test Failed", 214 | f"Failed to validate headers:\n{str(e)}\n\n" 215 | "Please ensure you copied the complete headers correctly.") 216 | return 217 | 218 | if save_config(self.config_data): 219 | messagebox.showinfo("Success", 220 | "Configuration saved and headers validated successfully!\n" 221 | "Your YouTube Music connection is working.") 222 | self.callback(self.config_data) 223 | self.dialog.destroy() 224 | else: 225 | messagebox.showerror("Error", "Failed to save configuration!") 226 | 227 | def show_spotify_instructions(self): 228 | instructions = """ 229 | 🎵 How to get Spotify credentials: 230 | 231 | 1. Go to https://developer.spotify.com/dashboard 232 | 2. Log in with your Spotify account 233 | 3. Click "Create App" 234 | 4. Fill in: 235 | - App name: "My Playlist Copier" (or any name) 236 | - App description: "Playlist transfer tool" 237 | - Redirect URI: http://127.0.0.1:8888/callback 238 | 5. Check the boxes and click "Save" 239 | 6. Click on your new app 240 | 7. Copy the "Client ID" and "Client Secret" 241 | 8. Paste them in the fields above 242 | 243 | Note: Keep your Client Secret private! 244 | """ 245 | 246 | msg_window = tk.Toplevel(self.dialog) 247 | msg_window.title("Spotify Setup Instructions") 248 | msg_window.geometry("500x400") 249 | msg_window.configure(bg='#1e1e1e') 250 | 251 | text_widget = tk.Text(msg_window, wrap="word", bg='#2d2d2d', fg='white', font=('Segoe UI', 10)) 252 | text_widget.pack(fill="both", expand=True, padx=20, pady=20) 253 | text_widget.insert("1.0", instructions) 254 | text_widget.config(state="disabled") 255 | 256 | def show_youtube_instructions(self): 257 | instructions = """ 258 | 🎼 How to get YouTube Music headers: 259 | 260 | STEP-BY-STEP GUIDE: 261 | 262 | 1. Open YouTube Music in your browser (music.youtube.com) 263 | 2. Make sure you're logged in to your account 264 | 3. Press F12 to open Developer Tools 265 | 4. Click on the "Network" tab 266 | 5. Press F5 to reload the page 267 | 6. In the network list, look for a request called "browse" 268 | 7. Click on the "browse" request 269 | 8. In the right panel, click "Headers" 270 | 9. Scroll down to "Request Headers" 271 | 10. Right-click in the Request Headers section 272 | 11. Select "Copy all as cURL" or "Copy request headers" 273 | 12. Paste EVERYTHING in the text box above 274 | 275 | IMPORTANT NOTES: 276 | • Your headers MUST include these fields: 277 | - cookie: (with VISITOR_INFO1_LIVE, PREF, etc.) 278 | - user-agent: (browser information) 279 | - authorization: (SAPISIDHASH...) 280 | - x-client-name: WEB_REMIX 281 | 282 | • Headers expire every 20-30 minutes - you'll need to update them 283 | • Make sure you copy ALL headers, not just some 284 | • If you get errors, try using an incognito/private browser window 285 | • The headers contain your login info - keep them private 286 | 287 | EXAMPLE of what your headers should look like: 288 | accept: */* 289 | accept-encoding: gzip, deflate, br 290 | accept-language: en-US,en;q=0.9 291 | authorization: SAPISIDHASH 1234567890_abcdef... 292 | cookie: VISITOR_INFO1_LIVE=abc123; PREF=f1=50000000... 293 | user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)... 294 | x-client-name: WEB_REMIX 295 | x-client-version: 1.20231115.01.00 296 | """ 297 | 298 | msg_window = tk.Toplevel(self.dialog) 299 | msg_window.title("YouTube Music Headers - Detailed Instructions") 300 | msg_window.geometry("700x650") 301 | msg_window.configure(bg='#1e1e1e') 302 | 303 | text_widget = tk.Text(msg_window, wrap="word", bg='#2d2d2d', fg='white', font=('Segoe UI', 9)) 304 | text_widget.pack(fill="both", expand=True, padx=20, pady=20) 305 | text_widget.insert("1.0", instructions) 306 | text_widget.config(state="disabled") 307 | 308 | class Spotify2YTMUI(tk.Tk): 309 | def __init__(self): 310 | super().__init__() 311 | 312 | self.progress_bar_state = { 313 | "current_value": 0, 314 | "maximum_value": 0, 315 | "paused": False 316 | } 317 | 318 | 319 | self.config_data = load_config() 320 | 321 | self.title("Spotify ➡️ YouTube Music Playlist Copier") 322 | self.geometry("700x950") 323 | self.resizable(True, True) 324 | self.configure(bg='#1e1e1e') 325 | 326 | self.style = ttk.Style() 327 | self.style.theme_use('clam') 328 | 329 | self.style.configure('Custom.TNotebook', background='#1e1e1e', borderwidth=0) 330 | self.style.configure('Custom.TNotebook.Tab', 331 | background='#2d2d2d', 332 | foreground='white', 333 | padding=[20, 10], 334 | font=('Segoe UI', 10)) 335 | self.style.map('Custom.TNotebook.Tab', 336 | background=[('selected', '#0078d4'), ('active', '#404044')]) 337 | 338 | self.style.configure('Custom.TFrame', background='#1e1e1e') 339 | self.style.configure('Custom.TButton', 340 | background='#0078d4', 341 | foreground='white', 342 | borderwidth=0, 343 | focuscolor='none', 344 | font=('Segoe UI', 10)) 345 | self.style.map('Custom.TButton', 346 | background=[('active', '#106ebe'), ('pressed', '#005a9e')]) 347 | 348 | self.style.configure('Green.TButton', 349 | background='#107c10', 350 | foreground='white', 351 | borderwidth=0, 352 | focuscolor='none', 353 | font=('Segoe UI', 10)) 354 | self.style.map('Green.TButton', 355 | background=[('active', '#0e6e0e'), ('pressed', '#0c5d0c')]) 356 | 357 | self.style.configure('Red.TButton', 358 | background='#d13438', 359 | foreground='white', 360 | borderwidth=0, 361 | focuscolor='none', 362 | font=('Segoe UI', 9)) 363 | self.style.map('Red.TButton', 364 | background=[('active', '#b92b2f'), ('pressed', '#a02327')]) 365 | 366 | main_frame = tk.Frame(self, bg='#1e1e1e') 367 | main_frame.pack(fill="both", expand=True, padx=20, pady=20) 368 | 369 | title_label = tk.Label(main_frame, 370 | text="Spotify ➡️ YouTube Music", 371 | font=('Segoe UI', 18, 'bold'), 372 | fg='white', 373 | bg='#1e1e1e') 374 | title_label.pack(pady=(0, 10)) 375 | 376 | subtitle_label = tk.Label(main_frame, 377 | text="Transfer your music seamlessly", 378 | font=('Segoe UI', 10), 379 | fg='#cccccc', 380 | bg='#1e1e1e') 381 | subtitle_label.pack(pady=(0, 20)) 382 | 383 | settings_btn = ttk.Button(main_frame, 384 | text="⚙️ Settings", 385 | command=self.open_settings, 386 | style='Custom.TButton') 387 | settings_btn.pack(pady=(0, 20)) 388 | 389 | self.create_batch_size_section(main_frame) 390 | 391 | self.notebook = ttk.Notebook(main_frame, style='Custom.TNotebook') 392 | self.notebook.pack(fill="both", expand=True, pady=(0, 15)) 393 | 394 | self.playlists_tab = ttk.Frame(self.notebook, style='Custom.TFrame') 395 | self.liked_tab = ttk.Frame(self.notebook, style='Custom.TFrame') 396 | self.artists_tab = ttk.Frame(self.notebook, style='Custom.TFrame') 397 | 398 | self.notebook.add(self.playlists_tab, text="🎵 Playlists") 399 | self.notebook.add(self.liked_tab, text="❤️ Liked Songs") 400 | self.notebook.add(self.artists_tab, text="👤 Artists") 401 | 402 | self.create_playlists_tab() 403 | self.create_liked_tab() 404 | self.create_artists_tab() 405 | 406 | status_frame = tk.Frame(main_frame, bg='#2d2d2d', relief='flat', bd=1) 407 | status_frame.pack(fill="x", pady=(0, 10)) 408 | 409 | self.progress = tk.StringVar() 410 | self.progress.set("Ready to transfer your music") 411 | status_label = tk.Label(status_frame, 412 | textvariable=self.progress, 413 | anchor="w", 414 | font=('Segoe UI', 9), 415 | fg='#cccccc', 416 | bg='#2d2d2d') 417 | status_label.pack(fill="x", padx=15, pady=8) 418 | 419 | self.progressbar = ttk.Progressbar(main_frame, 420 | orient="horizontal", 421 | mode="determinate", 422 | style='Custom.Horizontal.TProgressbar') 423 | self.progressbar.pack(fill="x", pady=(0, 15)) 424 | 425 | self.style.configure('Custom.Horizontal.TProgressbar', 426 | background='#0078d4', 427 | troughcolor='#404040', 428 | borderwidth=0, 429 | lightcolor='#0078d4', 430 | darkcolor='#0078d4') 431 | 432 | output_frame = tk.Frame(main_frame, bg='#1e1e1e') 433 | output_frame.pack(fill="both", expand=True) 434 | 435 | output_header = tk.Frame(output_frame, bg='#1e1e1e') 436 | output_header.pack(fill="x", pady=(0, 5)) 437 | 438 | tk.Label(output_header, 439 | text="Output Log", 440 | font=('Segoe UI', 11, 'bold'), 441 | fg='white', 442 | bg='#1e1e1e').pack(side="left") 443 | 444 | ttk.Button(output_header, 445 | text="Clear", 446 | command=self.clear_output, 447 | style='Red.TButton').pack(side="right") 448 | 449 | text_frame = tk.Frame(output_frame, bg='#2d2d2d', relief='flat', bd=1) 450 | text_frame.pack(fill="both", expand=True) 451 | 452 | self.response_text = tk.Text(text_frame, 453 | height=8, 454 | state="disabled", 455 | wrap="word", 456 | bg='#2d2d2d', 457 | fg='#ffffff', 458 | font=('Consolas', 9), 459 | insertbackground='white', 460 | selectbackground='#0078d4', 461 | relief='flat', 462 | bd=0) 463 | 464 | scrollbar = ttk.Scrollbar(text_frame, orient="vertical", command=self.response_text.yview) 465 | self.response_text.configure(yscrollcommand=scrollbar.set) 466 | 467 | self.response_text.pack(side="left", fill="both", expand=True, padx=10, pady=10) 468 | scrollbar.pack(side="right", fill="y", pady=10) 469 | 470 | 471 | def create_batch_size_section(self, parent): 472 | frame = tk.LabelFrame(parent, text="Batch Size", bg='#2d2d2d', fg='white', font=('Segoe UI', 11, 'bold')) 473 | frame.pack(fill="x", pady=(0, 15)) 474 | 475 | tk.Label(frame, text="Batch size (tracks per batch):", bg='#2d2d2d', fg='white', font=('Segoe UI', 10)).pack(anchor="w", padx=10, pady=(10, 0)) 476 | 477 | self.batch_slider = tk.Scale( 478 | frame, from_=1, to=20, orient=tk.HORIZONTAL, 479 | bg='#2d2d2d', fg='white', highlightthickness=0, 480 | showvalue=0, length=350, command=self.update_batch_display 481 | ) 482 | self.batch_slider.set(self.config_data.get("batch_size", 5)) 483 | self.batch_slider.pack(fill="x", padx=20, pady=(0, 0)) 484 | 485 | self.batch_value_label = tk.Label(frame, text="", bg='#2d2d2d', fg='#4ecdc4', font=('Segoe UI', 10, 'bold')) 486 | self.batch_value_label.pack(anchor="w", padx=20, pady=(0, 0)) 487 | 488 | self.batch_description = tk.Label(frame, text="", bg='#2d2d2d', fg='#cccccc', font=('Segoe UI', 9), wraplength=600, justify="left") 489 | self.batch_description.pack(anchor="w", padx=20, pady=(0, 10)) 490 | 491 | presets_frame = tk.Frame(frame, bg='#2d2d2d') 492 | presets_frame.pack(anchor="w", padx=20, pady=(0, 10)) 493 | tk.Label(presets_frame, text="Presets:", bg='#2d2d2d', fg='#cccccc', font=('Segoe UI', 9)).pack(side="left") 494 | for text, value in [("🔒 Safe", 3), ("✅ Balanced", 5), ("🚀 Fast", 10), ("⚡ Max", 15)]: 495 | tk.Button( 496 | presets_frame, text=text, font=('Segoe UI', 8, 'bold'), 497 | command=lambda v=value: self.set_batch_preset(v), 498 | bg='#404040', fg='white', relief='flat', padx=8, pady=2 499 | ).pack(side="left", padx=4) 500 | 501 | self.update_batch_display(self.batch_slider.get()) 502 | 503 | def update_batch_display(self, value): 504 | batch_size = int(float(value)) 505 | self.batch_value_label.config(text=f"Current: {batch_size} tracks per batch") 506 | if batch_size == 1: 507 | desc = "🐌 Single Track: Maximum reliability, very slow. Use only if having major issues." 508 | color = '#a8dadc' 509 | elif batch_size <= 3: 510 | desc = "🔒 Very Safe: High reliability, slower speed. Best for unstable connections." 511 | color = '#4ecdc4' 512 | elif batch_size <= 5: 513 | desc = "✅ Recommended: Good balance of speed and reliability. Works well for most users." 514 | color = '#6bcf7f' 515 | elif batch_size <= 8: 516 | desc = "🚀 Moderate Speed: Faster transfer, may occasionally miss tracks." 517 | color = '#ffd93d' 518 | elif batch_size <= 12: 519 | desc = "⚡ Fast: Quick transfer, higher chance of missing tracks due to rate limits." 520 | color = '#ff9500' 521 | else: 522 | desc = "🔥 Maximum Speed: Fastest but riskiest. May miss many tracks." 523 | color = '#ff6b6b' 524 | self.batch_description.config(text=desc, fg=color) 525 | self.config_data["batch_size"] = batch_size 526 | save_config(self.config_data) 527 | 528 | def set_batch_preset(self, value): 529 | self.batch_slider.set(value) 530 | self.update_batch_display(value) 531 | self.append_response(f"⚙️ Batch size set to {value} tracks per batch") 532 | 533 | 534 | 535 | def append_response(self, msg): 536 | self.response_text.config(state="normal") 537 | self.response_text.insert(tk.END, msg + "\n") 538 | self.response_text.see(tk.END) 539 | self.response_text.config(state="disabled") 540 | 541 | def clear_output(self): 542 | self.response_text.config(state="normal") 543 | self.response_text.delete(1.0, tk.END) 544 | self.response_text.config(state="disabled") 545 | 546 | def create_playlists_tab(self): 547 | container = tk.Frame(self.playlists_tab, bg='#1e1e1e') 548 | container.pack(fill="both", expand=True, padx=20, pady=20) 549 | 550 | instruction_label = tk.Label(container, 551 | text="Select playlists to transfer from Spotify to YouTube Music", 552 | font=('Segoe UI', 11), 553 | fg='#cccccc', 554 | bg='#1e1e1e') 555 | instruction_label.pack(pady=(0, 15)) 556 | 557 | listbox_frame = tk.Frame(container, bg='#2d2d2d', relief='flat', bd=1) 558 | listbox_frame.pack(fill="both", expand=True, pady=(0, 15)) 559 | 560 | self.playlists_listbox = tk.Listbox(listbox_frame, 561 | selectmode=tk.MULTIPLE, 562 | bg='#2d2d2d', 563 | fg='white', 564 | font=('Segoe UI', 10), 565 | selectbackground='#0078d4', 566 | selectforeground='white', 567 | relief='flat', 568 | bd=0, 569 | highlightthickness=0) 570 | 571 | listbox_scrollbar = ttk.Scrollbar(listbox_frame, orient="vertical", command=self.playlists_listbox.yview) 572 | self.playlists_listbox.configure(yscrollcommand=listbox_scrollbar.set) 573 | 574 | self.playlists_listbox.pack(side="left", fill="both", expand=True, padx=10, pady=10) 575 | listbox_scrollbar.pack(side="right", fill="y", pady=10) 576 | 577 | btn_frame = tk.Frame(container, bg='#1e1e1e') 578 | btn_frame.pack(fill="x") 579 | 580 | ttk.Button(btn_frame, 581 | text="🔄 Load Playlists", 582 | command=self.load_playlists, 583 | style='Custom.TButton').pack(side="left", padx=(0, 10)) 584 | 585 | ttk.Button(btn_frame, 586 | text="📋 Copy Selected", 587 | command=self.copy_selected_playlists, 588 | style='Green.TButton').pack(side="left", padx=(0, 10)) 589 | 590 | ttk.Button(btn_frame, 591 | text="📚 Copy All", 592 | command=self.copy_all_playlists, 593 | style='Green.TButton').pack(side="left") 594 | 595 | def create_liked_tab(self): 596 | container = tk.Frame(self.liked_tab, bg='#1e1e1e') 597 | container.pack(expand=True) 598 | 599 | tk.Label(container, 600 | text="❤️", 601 | font=('Segoe UI', 48), 602 | fg='#ff6b6b', 603 | bg='#1e1e1e').pack(pady=(40, 20)) 604 | 605 | tk.Label(container, 606 | text="Transfer Your Liked Songs", 607 | font=('Segoe UI', 16, 'bold'), 608 | fg='white', 609 | bg='#1e1e1e').pack(pady=(0, 10)) 610 | 611 | tk.Label(container, 612 | text="Copy all your Spotify liked songs to a YouTube Music playlist", 613 | font=('Segoe UI', 11), 614 | fg='#cccccc', 615 | bg='#1e1e1e').pack(pady=(0, 30)) 616 | 617 | ttk.Button(container, 618 | text="💖 Transfer Liked Songs", 619 | command=self.copy_liked_songs, 620 | style='Green.TButton').pack() 621 | 622 | def create_artists_tab(self): 623 | container = tk.Frame(self.artists_tab, bg='#1e1e1e') 624 | container.pack(expand=True) 625 | 626 | tk.Label(container, 627 | text="👤", 628 | font=('Segoe UI', 48), 629 | fg='#4ecdc4', 630 | bg='#1e1e1e').pack(pady=(40, 20)) 631 | 632 | tk.Label(container, 633 | text="Follow Your Artists", 634 | font=('Segoe UI', 16, 'bold'), 635 | fg='white', 636 | bg='#1e1e1e').pack(pady=(0, 10)) 637 | 638 | tk.Label(container, 639 | text="Subscribe to your followed Spotify artists on YouTube Music", 640 | font=('Segoe UI', 11), 641 | fg='#cccccc', 642 | bg='#1e1e1e').pack(pady=(0, 30)) 643 | 644 | ttk.Button(container, 645 | text="👥 Follow Artists", 646 | command=self.copy_followed_artists, 647 | style='Green.TButton').pack() 648 | 649 | def load_playlists(self): 650 | if not self.check_configuration(): 651 | return 652 | self.playlists_listbox.delete(0, tk.END) 653 | self.progress.set("Loading playlists from Spotify...") 654 | self.playlists = copy_playlists.list_spotify_playlists() 655 | for idx, playlist in enumerate(self.playlists): 656 | name = playlist['name'] 657 | total = playlist['tracks']['total'] if 'tracks' in playlist and 'total' in playlist['tracks'] else "?" 658 | self.playlists_listbox.insert(tk.END, f"🎵 {name} ({total} tracks)") 659 | self.append_response("✅ Loaded playlists successfully") 660 | self.progress.set(f"Loaded {len(self.playlists)} playlists") 661 | 662 | def copy_selected_playlists(self): 663 | if not self.check_configuration(): 664 | return 665 | if not self.check_api_quotas(): 666 | return 667 | selected = self.playlists_listbox.curselection() 668 | if not selected: 669 | messagebox.showinfo("No Selection", "Please select at least one playlist.") 670 | return 671 | playlists = [self.playlists[i] for i in selected] 672 | threading.Thread(target=self._copy_playlists, args=(playlists,)).start() 673 | 674 | def copy_all_playlists(self): 675 | if not self.check_configuration(): 676 | return 677 | if not self.check_api_quotas(): 678 | return 679 | threading.Thread(target=self._copy_playlists, args=(self.playlists,)).start() 680 | 681 | def pause_progress_bar(self): 682 | self.progress_bar_state["current_value"] = self.progressbar["value"] 683 | self.progress_bar_state["maximum_value"] = self.progressbar["maximum"] 684 | self.progress_bar_state["paused"] = True 685 | 686 | def resume_progress_bar(self): 687 | if self.progress_bar_state["paused"]: 688 | self.progressbar["maximum"] = self.progress_bar_state["maximum_value"] 689 | self.progressbar["value"] = self.progress_bar_state["current_value"] 690 | self.progress_bar_state["paused"] = False 691 | 692 | def reset_progress_bar(self): 693 | self.progress_bar_state = { 694 | "current_value": 0, 695 | "maximum_value": 0, 696 | "paused": False 697 | } 698 | self.progressbar["value"] = 0 699 | self.progressbar["maximum"] = 100 700 | 701 | def show_header_expired_dialog(self, playlist_name, progress_file, operation_type="playlist"): 702 | self.pause_progress_bar() 703 | 704 | result = messagebox.askyesno( 705 | "Headers Expired", 706 | f"🔑 YouTube Music headers have expired!\n\n" 707 | f"Progress has been saved for: {playlist_name}\n\n" 708 | f"Would you like to update your headers now?\n" 709 | f"Click 'Yes' to open settings, or 'No' to stop the transfer.", 710 | icon='warning' 711 | ) 712 | 713 | if result: 714 | self.pending_resume = { 715 | "playlist_name": playlist_name, 716 | "operation_type": operation_type 717 | } 718 | 719 | def on_save(new_config): 720 | self.config_data = new_config 721 | self.update_copy_playlists_config() 722 | self.resume_progress_bar() 723 | messagebox.showinfo("Headers Updated", 724 | "Headers updated successfully!\n" 725 | "The transfer will now resume automatically.") 726 | self._resume_transfer() 727 | 728 | SettingsDialog(self, self.config_data, on_save) 729 | else: 730 | self.reset_progress_bar() 731 | self.progress.set("Transfer stopped - headers expired") 732 | self.append_response(f"⏸️ Transfer stopped. Progress saved to: {progress_file}") 733 | 734 | def _resume_transfer(self): 735 | if not hasattr(self, 'pending_resume'): 736 | return 737 | 738 | resume_info = self.pending_resume 739 | playlist_name = resume_info["playlist_name"] 740 | operation_type = resume_info["operation_type"] 741 | remaining_playlists = resume_info.get("remaining_playlists", []) 742 | 743 | self.append_response(f"🔄 Resuming transfer for: {playlist_name}") 744 | 745 | if operation_type == "playlist": 746 | if hasattr(self, 'playlists'): 747 | for playlist in self.playlists: 748 | if playlist['name'] == playlist_name: 749 | threading.Thread(target=self._copy_playlists, args=([playlist],)).start() 750 | break 751 | if remaining_playlists: 752 | for pl_name in remaining_playlists: 753 | for playlist in self.playlists: 754 | if playlist['name'] == pl_name: 755 | threading.Thread(target=self._copy_playlists, args=([playlist],)).start() 756 | elif operation_type == "liked_songs": 757 | threading.Thread(target=self._copy_liked_songs).start() 758 | 759 | delattr(self, 'pending_resume') 760 | 761 | def _copy_playlists(self, playlists): 762 | for playlist in playlists: 763 | name = playlist['name'] 764 | playlist_id = playlist['id'] 765 | 766 | progress = copy_playlists.load_progress(name) 767 | if progress: 768 | self.append_response(f"📁 Found saved progress for '{name}'. Resuming...") 769 | tracks = copy_playlists.get_spotify_playlist_tracks(playlist_id) 770 | start_index = progress.get("current_track_index", 0) 771 | ytm_video_ids = progress["ytm_video_ids"] 772 | not_found_tracks = progress["not_found_tracks"] 773 | current_batch_index = progress.get("current_batch_index", 0) 774 | 775 | if current_batch_index is None: 776 | current_batch_index = 0 777 | self.append_response(f"⚠️ Batch index was null, starting from beginning of batching phase") 778 | else: 779 | self.progress.set(f"Processing: {name}") 780 | self.append_response(f"🎵 Processing playlist: {name}") 781 | tracks = copy_playlists.get_spotify_playlist_tracks(playlist_id) 782 | if not tracks: 783 | self.append_response(f"⚠️ No tracks found in playlist: {name}") 784 | continue 785 | start_index = 0 786 | ytm_video_ids = [] 787 | not_found_tracks = [] 788 | current_batch_index = 0 789 | 790 | ytm_playlist_id, already_exists = copy_playlists.create_or_get_ytm_playlist(name) 791 | if not ytm_playlist_id: 792 | self.append_response(f"❌ Failed to create playlist: {name}") 793 | continue 794 | 795 | existing_video_ids = set() 796 | if already_exists and not progress: 797 | self.append_response(f"📋 Playlist exists, checking for new songs...") 798 | existing_video_ids = copy_playlists.get_ytm_playlist_song_video_ids(ytm_playlist_id) 799 | 800 | if not self.progress_bar_state["paused"]: 801 | self.progressbar["maximum"] = len(tracks) 802 | self.progressbar["value"] = start_index 803 | 804 | batch_size = int(self.batch_slider.get()) 805 | 806 | try: 807 | if progress and ytm_video_ids: 808 | self.append_response(f"📤 Resuming: Adding remaining tracks from batch {current_batch_index + 1}...") 809 | self.append_response(f"⚙️ Using batch size: {batch_size} tracks per batch") 810 | 811 | if not self.progress_bar_state["paused"]: 812 | self.progressbar["maximum"] = len(ytm_video_ids) 813 | completed_tracks = current_batch_index * batch_size 814 | self.progressbar["value"] = completed_tracks 815 | 816 | def progress_callback(current): 817 | base = current_batch_index * batch_size 818 | total_progress = base + current 819 | if not self.progress_bar_state["paused"]: 820 | self.progressbar["value"] = min(total_progress, len(ytm_video_ids)) 821 | self.progress.set(f"Adding tracks: {total_progress}/{len(ytm_video_ids)}") 822 | self.update_idletasks() 823 | 824 | try: 825 | actually_added, failed_batches = copy_playlists.add_tracks_with_delayed_verification( 826 | ytm_playlist_id, 827 | ytm_video_ids, 828 | batch_size=batch_size, 829 | batch_delay=5, 830 | verification_delay=30, 831 | progress_callback=progress_callback, 832 | start_batch_index=current_batch_index 833 | ) 834 | 835 | if not self.progress_bar_state["paused"]: 836 | self.progressbar["value"] = len(ytm_video_ids) 837 | 838 | if len(actually_added) == len(ytm_video_ids): 839 | self.append_response(f"✅ Perfect success! All {len(actually_added)} tracks added to: {name}") 840 | elif len(actually_added) > 0: 841 | success_rate = (len(actually_added) / len(ytm_video_ids)) * 100 842 | self.append_response(f"⚠️ Partial success: {len(actually_added)}/{len(ytm_video_ids)} tracks added ({success_rate:.1f}%)") 843 | self.append_response(f" Missing {len(ytm_video_ids) - len(actually_added)} tracks may appear later due to YouTube Music delays") 844 | else: 845 | self.append_response(f"❌ No tracks were successfully added to: {name}") 846 | 847 | if failed_batches: 848 | failed_count = sum(len(batch) for batch in failed_batches) 849 | self.append_response(f"⚠️ {failed_count} tracks failed during batch adding (network/API issues)") 850 | 851 | except copy_playlists.HeaderExpiredError as e: 852 | expired_batch_index = getattr(e, "batch_index", current_batch_index) 853 | self.append_response(f"🔑 Headers expired during batch {expired_batch_index + 1}") 854 | 855 | progress_file = copy_playlists.save_progress( 856 | name, len(tracks), len(tracks), ytm_video_ids, not_found_tracks, "playlist", 857 | current_batch_index=expired_batch_index 858 | ) 859 | self.show_header_expired_dialog(name, progress_file, "playlist") 860 | return 861 | 862 | else: 863 | for idx in range(start_index, len(tracks)): 864 | track = tracks[idx] 865 | video_id = copy_playlists.search_track_on_ytm(track) 866 | if video_id and video_id not in existing_video_ids: 867 | ytm_video_ids.append(video_id) 868 | elif not video_id: 869 | not_found_tracks.append(track) 870 | 871 | if not self.progress_bar_state["paused"]: 872 | self.progressbar["value"] = idx + 1 873 | self.progress.set(f"Searching: {idx + 1}/{len(tracks)} - {track[:50]}...") 874 | self.update_idletasks() 875 | 876 | if ytm_video_ids and not self.progress_bar_state["paused"]: 877 | self.progressbar["maximum"] = len(ytm_video_ids) 878 | self.progressbar["value"] = 0 879 | 880 | if ytm_video_ids: 881 | try: 882 | self.append_response(f"📤 Adding {len(ytm_video_ids)} tracks with batch size {batch_size}...") 883 | 884 | def progress_callback(current): 885 | base = current_batch_index * batch_size 886 | total_progress = base + current 887 | if not self.progress_bar_state["paused"]: 888 | self.progressbar["value"] = min(total_progress, len(ytm_video_ids)) 889 | self.progress.set(f"Adding tracks: {total_progress}/{len(ytm_video_ids)}") 890 | self.update_idletasks() 891 | 892 | actually_added, failed_batches = copy_playlists.add_tracks_with_delayed_verification( 893 | ytm_playlist_id, 894 | ytm_video_ids, 895 | batch_size=batch_size, 896 | batch_delay=5, 897 | verification_delay=30, 898 | progress_callback=progress_callback, 899 | start_batch_index=0 900 | ) 901 | 902 | if not self.progress_bar_state["paused"]: 903 | self.progressbar["value"] = len(ytm_video_ids) 904 | 905 | if len(actually_added) == len(ytm_video_ids): 906 | self.append_response(f"✅ Perfect success! All {len(actually_added)} tracks added to: {name}") 907 | elif len(actually_added) > 0: 908 | success_rate = (len(actually_added) / len(ytm_video_ids)) * 100 909 | self.append_response(f"⚠️ Partial success: {len(actually_added)}/{len(ytm_video_ids)} tracks added ({success_rate:.1f}%)") 910 | self.append_response(f" Missing {len(ytm_video_ids) - len(actually_added)} tracks may appear later due to YouTube Music delays") 911 | else: 912 | self.append_response(f"❌ No tracks were successfully added to: {name}") 913 | 914 | if failed_batches: 915 | failed_count = sum(len(batch) for batch in failed_batches) 916 | self.append_response(f"⚠️ {failed_count} tracks failed during batch adding (network/API issues)") 917 | 918 | except copy_playlists.HeaderExpiredError as e: 919 | expired_batch_index = getattr(e, "batch_index", 0) 920 | self.append_response(f"🔑 Headers expired during batch {expired_batch_index + 1}") 921 | 922 | progress_file = copy_playlists.save_progress( 923 | name, len(tracks), len(tracks), ytm_video_ids, not_found_tracks, "playlist", 924 | current_batch_index=expired_batch_index 925 | ) 926 | self.show_header_expired_dialog(name, progress_file, "playlist") 927 | return 928 | else: 929 | self.append_response(f"ℹ️ No new tracks to add for: {name}") 930 | if not self.progress_bar_state["paused"]: 931 | self.progressbar["maximum"] = 1 932 | self.progressbar["value"] = 1 933 | 934 | if not_found_tracks: 935 | self.append_response(f"⚠️ {len(not_found_tracks)} tracks not found on YouTube Music") 936 | for track in not_found_tracks[:10]: 937 | self.append_response(f" • {track}") 938 | if len(not_found_tracks) > 10: 939 | self.append_response(f" ... and {len(not_found_tracks) - 10} more") 940 | 941 | copy_playlists.delete_progress(name) 942 | self.reset_progress_bar() 943 | 944 | except copy_playlists.HeaderExpiredError: 945 | progress_file = copy_playlists.save_progress( 946 | name, idx, len(tracks), ytm_video_ids, not_found_tracks, "playlist" 947 | ) 948 | self.show_header_expired_dialog(name, progress_file, "playlist") 949 | return 950 | 951 | self.progress.set("✅ Playlist transfer completed") 952 | self.append_response("🎉 Finished copying all playlists!") 953 | messagebox.showinfo("Success", "Playlists transferred successfully!") 954 | 955 | def _copy_liked_songs(self): 956 | playlist_name = "Liked Songs from Spotify" 957 | 958 | progress = copy_playlists.load_progress(playlist_name) 959 | if progress: 960 | self.append_response(f"📁 Found saved progress for '{playlist_name}'. Resuming...") 961 | liked_songs = copy_playlists.get_spotify_liked_songs() 962 | start_index = progress["current_track_index"] 963 | ytm_video_ids = progress["ytm_video_ids"] 964 | not_found_tracks = progress["not_found_tracks"] 965 | current_batch_index = progress.get("current_batch_index", 0) 966 | 967 | if current_batch_index is None: 968 | current_batch_index = 0 969 | self.append_response(f"⚠️ Batch index was null, starting from beginning of batching phase") 970 | else: 971 | self.progress.set("Fetching liked songs...") 972 | self.append_response("💖 Fetching liked songs from Spotify...") 973 | liked_songs = copy_playlists.get_spotify_liked_songs() 974 | if not liked_songs: 975 | self.progress.set("No liked songs found") 976 | self.append_response("⚠️ No liked songs found on Spotify") 977 | messagebox.showinfo("No Liked Songs", "No liked songs found on Spotify.") 978 | return 979 | start_index = 0 980 | ytm_video_ids = [] 981 | not_found_tracks = [] 982 | current_batch_index = 0 983 | 984 | ytm_playlist_id, already_exists = copy_playlists.create_or_get_ytm_playlist(playlist_name) 985 | if not ytm_playlist_id: 986 | self.progress.set("Failed to create playlist") 987 | self.append_response("❌ Failed to create playlist on YouTube Music") 988 | return 989 | 990 | existing_video_ids = set() 991 | if already_exists and not progress: 992 | self.append_response("📋 Playlist exists, checking for new songs...") 993 | existing_video_ids = copy_playlists.get_ytm_playlist_song_video_ids(ytm_playlist_id) 994 | 995 | if not self.progress_bar_state["paused"]: 996 | self.progressbar["maximum"] = len(liked_songs) 997 | self.progressbar["value"] = start_index 998 | 999 | batch_size = int(self.batch_slider.get()) 1000 | 1001 | try: 1002 | if progress and ytm_video_ids: 1003 | self.append_response(f"📤 Resuming: Adding remaining liked songs from batch {current_batch_index + 1}...") 1004 | self.append_response(f"⚙️ Using batch size: {batch_size} tracks per batch") 1005 | 1006 | if not self.progress_bar_state["paused"]: 1007 | self.progressbar["maximum"] = len(ytm_video_ids) 1008 | completed_tracks = current_batch_index * batch_size 1009 | self.progressbar["value"] = completed_tracks 1010 | 1011 | def progress_callback(current): 1012 | base = current_batch_index * batch_size 1013 | total_progress = base + current 1014 | if not self.progress_bar_state["paused"]: 1015 | self.progressbar["value"] = min(total_progress, len(ytm_video_ids)) 1016 | self.progress.set(f"Adding tracks: {total_progress}/{len(ytm_video_ids)}") 1017 | self.update_idletasks() 1018 | 1019 | try: 1020 | actually_added, failed_batches = copy_playlists.add_tracks_with_delayed_verification( 1021 | ytm_playlist_id, 1022 | ytm_video_ids, 1023 | batch_size=batch_size, 1024 | batch_delay=5, 1025 | verification_delay=30, 1026 | progress_callback=progress_callback, 1027 | start_batch_index=current_batch_index 1028 | ) 1029 | 1030 | if not self.progress_bar_state["paused"]: 1031 | self.progressbar["value"] = len(ytm_video_ids) 1032 | 1033 | if len(actually_added) == len(ytm_video_ids): 1034 | self.append_response(f"✅ Perfect success! All {len(actually_added)} liked songs added") 1035 | elif len(actually_added) > 0: 1036 | success_rate = (len(actually_added) / len(ytm_video_ids)) * 100 1037 | self.append_response(f"⚠️ Partial success: {len(actually_added)}/{len(ytm_video_ids)} liked songs added ({success_rate:.1f}%)") 1038 | self.append_response(f" Missing {len(ytm_video_ids) - len(actually_added)} tracks may appear later due to YouTube Music delays") 1039 | else: 1040 | self.append_response(f"❌ No liked songs were successfully added") 1041 | 1042 | except copy_playlists.HeaderExpiredError as e: 1043 | expired_batch_index = getattr(e, "batch_index", current_batch_index) 1044 | self.append_response(f"🔑 Headers expired during batch {expired_batch_index + 1}") 1045 | 1046 | progress_file = copy_playlists.save_progress( 1047 | playlist_name, len(liked_songs), len(liked_songs), ytm_video_ids, not_found_tracks, "liked_songs", 1048 | current_batch_index=expired_batch_index 1049 | ) 1050 | self.show_header_expired_dialog(playlist_name, progress_file, "liked_songs") 1051 | return 1052 | 1053 | else: 1054 | for idx in range(start_index, len(liked_songs)): 1055 | track = liked_songs[idx] 1056 | video_id = copy_playlists.search_track_on_ytm(track) 1057 | if video_id and video_id not in existing_video_ids: 1058 | ytm_video_ids.append(video_id) 1059 | elif not video_id: 1060 | not_found_tracks.append(track) 1061 | 1062 | if not self.progress_bar_state["paused"]: 1063 | self.progressbar["value"] = idx + 1 1064 | self.progress.set(f"Searching: {idx + 1}/{len(liked_songs)} - {track[:50]}...") 1065 | self.update_idletasks() 1066 | 1067 | if ytm_video_ids and not self.progress_bar_state["paused"]: 1068 | self.progressbar["maximum"] = len(ytm_video_ids) 1069 | self.progressbar["value"] = 0 1070 | 1071 | if ytm_video_ids: 1072 | try: 1073 | self.append_response(f"📤 Adding {len(ytm_video_ids)} liked songs with batch size {batch_size}...") 1074 | 1075 | def progress_callback(current): 1076 | base = current_batch_index * batch_size 1077 | total_progress = base + current 1078 | if not self.progress_bar_state["paused"]: 1079 | self.progressbar["value"] = min(total_progress, len(ytm_video_ids)) 1080 | self.progress.set(f"Adding tracks: {total_progress}/{len(ytm_video_ids)}") 1081 | self.update_idletasks() 1082 | 1083 | actually_added, failed_batches = copy_playlists.add_tracks_with_delayed_verification( 1084 | ytm_playlist_id, 1085 | ytm_video_ids, 1086 | batch_size=batch_size, 1087 | batch_delay=5, 1088 | verification_delay=30, 1089 | progress_callback=progress_callback, 1090 | start_batch_index=0 1091 | ) 1092 | 1093 | if not self.progress_bar_state["paused"]: 1094 | self.progressbar["value"] = len(ytm_video_ids) 1095 | 1096 | if len(actually_added) == len(ytm_video_ids): 1097 | self.append_response(f"✅ Perfect success! All {len(actually_added)} liked songs added") 1098 | elif len(actually_added) > 0: 1099 | success_rate = (len(actually_added) / len(ytm_video_ids)) * 100 1100 | self.append_response(f"⚠️ Partial success: {len(actually_added)}/{len(ytm_video_ids)} liked songs added ({success_rate:.1f}%)") 1101 | self.append_response(f" Missing {len(ytm_video_ids) - len(actually_added)} tracks may appear later due to YouTube Music delays") 1102 | else: 1103 | self.append_response(f"❌ No liked songs were successfully added") 1104 | 1105 | except copy_playlists.HeaderExpiredError as e: 1106 | expired_batch_index = getattr(e, "batch_index", 0) 1107 | self.append_response(f"🔑 Headers expired during batch {expired_batch_index + 1}") 1108 | 1109 | progress_file = copy_playlists.save_progress( 1110 | playlist_name, len(liked_songs), len(liked_songs), ytm_video_ids, not_found_tracks, "liked_songs", 1111 | current_batch_index=expired_batch_index 1112 | ) 1113 | self.show_header_expired_dialog(playlist_name, progress_file, "liked_songs") 1114 | return 1115 | else: 1116 | self.append_response("ℹ️ No new liked songs to add") 1117 | if not self.progress_bar_state["paused"]: 1118 | self.progressbar["maximum"] = 1 1119 | self.progressbar["value"] = 1 1120 | 1121 | if not_found_tracks: 1122 | self.append_response(f"⚠️ {len(not_found_tracks)} songs not found on YouTube Music") 1123 | for track in not_found_tracks[:10]: 1124 | self.append_response(f" • {track}") 1125 | if len(not_found_tracks) > 10: 1126 | self.append_response(f" ... and {len(not_found_tracks) - 10} more") 1127 | 1128 | copy_playlists.delete_progress(playlist_name) 1129 | self.reset_progress_bar() 1130 | 1131 | except copy_playlists.HeaderExpiredError: 1132 | progress_file = copy_playlists.save_progress( 1133 | playlist_name, idx, len(liked_songs), ytm_video_ids, not_found_tracks, "liked_songs" 1134 | ) 1135 | self.show_header_expired_dialog(playlist_name, progress_file, "liked_songs") 1136 | return 1137 | 1138 | self.progress.set("✅ Liked songs transfer completed") 1139 | self.append_response("🎉 Finished copying liked songs!") 1140 | messagebox.showinfo("Success", "Liked songs transferred successfully!") 1141 | 1142 | def copy_liked_songs(self): 1143 | if not self.check_configuration(): 1144 | return 1145 | if not self.check_api_quotas(): 1146 | return 1147 | threading.Thread(target=self._copy_liked_songs).start() 1148 | 1149 | def copy_followed_artists(self): 1150 | if not self.check_configuration(): 1151 | return 1152 | threading.Thread(target=self._copy_followed_artists).start() 1153 | 1154 | def _copy_followed_artists(self): 1155 | self.progress.set("Fetching followed artists...") 1156 | self.append_response("👤 Fetching followed artists from Spotify...") 1157 | artists = copy_playlists.get_spotify_followed_artists() 1158 | if not artists: 1159 | self.progress.set("No followed artists found") 1160 | self.append_response("⚠️ No followed artists found on Spotify") 1161 | messagebox.showinfo("No Artists", "No followed artists found on Spotify.") 1162 | return 1163 | 1164 | self.append_response(f"🔄 Subscribing to {len(artists)} artists...") 1165 | copy_playlists.subscribe_to_ytm_artists(artists) 1166 | self.progress.set("✅ Artist subscription completed") 1167 | self.append_response("🎉 Finished subscribing to artists!") 1168 | messagebox.showinfo("Success", "Artists followed successfully!") 1169 | 1170 | def open_settings(self): 1171 | def on_save(new_config): 1172 | self.config_data = new_config 1173 | self.update_copy_playlists_config() 1174 | 1175 | SettingsDialog(self, self.config_data, on_save) 1176 | 1177 | def update_copy_playlists_config(self): 1178 | try: 1179 | if self.config_data.get("youtube_headers"): 1180 | with open("raw_headers.txt", "w", encoding="utf-8") as f: 1181 | f.write(self.config_data["youtube_headers"]) 1182 | 1183 | from ytmusicapi import setup 1184 | if self.config_data.get("youtube_headers"): 1185 | setup(filepath="browser.json", headers_raw=self.config_data["youtube_headers"]) 1186 | 1187 | copy_playlists.initialize_clients(self.config_data) 1188 | 1189 | self.append_response("✅ Configuration updated successfully!") 1190 | 1191 | except Exception as e: 1192 | self.append_response(f"❌ Error updating configuration: {e}") 1193 | messagebox.showerror("Configuration Error", f"Failed to update configuration:\n{e}") 1194 | 1195 | def check_configuration(self): 1196 | if not self.config_data.get("spotify_client_id") or not self.config_data.get("spotify_client_secret"): 1197 | messagebox.showerror("Configuration Required", 1198 | "Please configure your Spotify credentials in Settings first!") 1199 | return False 1200 | 1201 | if not self.config_data.get("youtube_headers"): 1202 | messagebox.showerror("Configuration Required", 1203 | "Please configure your YouTube Music headers in Settings first!") 1204 | return False 1205 | 1206 | return True 1207 | 1208 | def check_api_quotas(self): 1209 | self.append_response("🔍 Checking API quotas...") 1210 | try: 1211 | ok, feedback = copy_playlists.perform_quota_check() 1212 | self.append_response(feedback) 1213 | if "track count is 0" in feedback.lower(): 1214 | proceed = messagebox.askyesno( 1215 | "YouTube Music Quota/Cache Warning", 1216 | feedback + "\n\nDo you want to continue anyway?" 1217 | ) 1218 | if proceed: 1219 | self.append_response("⚠️ Proceeding despite track count/cache warning.") 1220 | return True 1221 | else: 1222 | self.append_response("❌ User cancelled due to track count/cache warning.") 1223 | return False 1224 | elif ok: 1225 | self.append_response("✅ All APIs ready!") 1226 | return True 1227 | else: 1228 | self.append_response("❌ API quota check failed!") 1229 | messagebox.showerror("API Quota Error", feedback) 1230 | return False 1231 | except Exception as e: 1232 | self.append_response(f"❌ Quota check error: {e}") 1233 | messagebox.showerror("Quota Check Failed", f"Failed to check API quotas:\n{e}") 1234 | return False 1235 | 1236 | def update_verification_progress(self, current_batch, total_batches, added_count, total_tracks): 1237 | self.progress.set(f"Verifying batch {current_batch}/{total_batches} - {added_count}/{total_tracks} tracks added") 1238 | self.update_idletasks() 1239 | 1240 | def update_batch_progress(self, current, total): 1241 | self.progressbar["value"] = current 1242 | self.progress.set(f"Adding tracks: {current}/{total}") 1243 | self.update_idletasks() 1244 | 1245 | if __name__ == "__main__": 1246 | config = load_config() 1247 | app = Spotify2YTMUI() 1248 | app.mainloop() --------------------------------------------------------------------------------