├── 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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ---
16 |
17 |
18 |
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()
--------------------------------------------------------------------------------