Синхронизация Яндекс Музыки со Spotify

Синхронизирует вашу коллекцию треков из Яндекс Музыки с лайкнутыми треками в Spotify

Size

18.1 KB

Version

1.0.1

Created

Mar 12, 2026

Updated

9 days ago

1// ==UserScript==
2// @name		Синхронизация Яндекс Музыки со Spotify
3// @description		Синхронизирует вашу коллекцию треков из Яндекс Музыки с лайкнутыми треками в Spotify
4// @version		1.0.1
5// @match		https://*.music.yandex.ru/*
6// @icon		https://music.yandex.ru/favicon.svg
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    // Конфигурация Spotify
12    const SPOTIFY_CONFIG = {
13        clientId: '',
14        redirectUri: 'https://music.yandex.ru/collection',
15        scopes: 'user-library-modify user-library-read'
16    };
17
18    // Утилита для debounce
19    function debounce(func, wait) {
20        let timeout;
21        return function executedFunction(...args) {
22            const later = () => {
23                clearTimeout(timeout);
24                func(...args);
25            };
26            clearTimeout(timeout);
27            timeout = setTimeout(later, wait);
28        };
29    }
30
31    // Класс для работы со Spotify API
32    class SpotifySync {
33        constructor() {
34            this.accessToken = null;
35            this.isAuthorized = false;
36        }
37
38        async init() {
39            // Проверяем, есть ли сохраненный токен
40            const savedToken = await GM.getValue('spotify_access_token');
41            const tokenExpiry = await GM.getValue('spotify_token_expiry');
42            
43            if (savedToken && tokenExpiry && Date.now() < tokenExpiry) {
44                this.accessToken = savedToken;
45                this.isAuthorized = true;
46                console.log('Spotify: используем сохраненный токен');
47            } else {
48                // Проверяем, есть ли токен в URL (после редиректа)
49                this.checkUrlForToken();
50            }
51        }
52
53        checkUrlForToken() {
54            const hash = window.location.hash;
55            if (hash.includes('access_token')) {
56                const params = new URLSearchParams(hash.substring(1));
57                this.accessToken = params.get('access_token');
58                const expiresIn = parseInt(params.get('expires_in')) * 1000;
59                
60                // Сохраняем токен
61                GM.setValue('spotify_access_token', this.accessToken);
62                GM.setValue('spotify_token_expiry', Date.now() + expiresIn);
63                
64                this.isAuthorized = true;
65                console.log('Spotify: получен новый токен');
66                
67                // Очищаем URL от токена
68                window.history.replaceState({}, document.title, window.location.pathname);
69            }
70        }
71
72        authorize() {
73            const clientId = SPOTIFY_CONFIG.clientId;
74            if (!clientId) {
75                alert('Пожалуйста, настройте Spotify Client ID в коде расширения');
76                return;
77            }
78
79            const authUrl = `https://accounts.spotify.com/authorize?` +
80                `client_id=${clientId}&` +
81                `response_type=token&` +
82                `redirect_uri=${encodeURIComponent(SPOTIFY_CONFIG.redirectUri)}&` +
83                `scope=${encodeURIComponent(SPOTIFY_CONFIG.scopes)}`;
84            
85            window.location.href = authUrl;
86        }
87
88        async searchTrack(trackName, artistName) {
89            if (!this.isAuthorized) {
90                throw new Error('Не авторизован в Spotify');
91            }
92
93            const query = `track:${trackName} artist:${artistName}`;
94            const url = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=1`;
95
96            try {
97                const response = await GM.xmlhttpRequest({
98                    method: 'GET',
99                    url: url,
100                    headers: {
101                        'Authorization': `Bearer ${this.accessToken}`
102                    },
103                    responseType: 'json'
104                });
105
106                const data = typeof response.response === 'string' 
107                    ? JSON.parse(response.response) 
108                    : response.response;
109
110                if (data.tracks && data.tracks.items && data.tracks.items.length > 0) {
111                    return data.tracks.items[0].id;
112                }
113                return null;
114            } catch (error) {
115                console.error('Ошибка поиска трека в Spotify:', error);
116                return null;
117            }
118        }
119
120        async addTracksToLiked(trackIds) {
121            if (!this.isAuthorized) {
122                throw new Error('Не авторизован в Spotify');
123            }
124
125            // Spotify API позволяет добавлять до 50 треков за раз
126            const chunks = [];
127            for (let i = 0; i < trackIds.length; i += 50) {
128                chunks.push(trackIds.slice(i, i + 50));
129            }
130
131            for (const chunk of chunks) {
132                try {
133                    await GM.xmlhttpRequest({
134                        method: 'PUT',
135                        url: 'https://api.spotify.com/v1/me/tracks',
136                        headers: {
137                            'Authorization': `Bearer ${this.accessToken}`,
138                            'Content-Type': 'application/json'
139                        },
140                        data: JSON.stringify({ ids: chunk })
141                    });
142                    console.log(`Добавлено ${chunk.length} треков в Spotify`);
143                } catch (error) {
144                    console.error('Ошибка добавления треков:', error);
145                }
146            }
147        }
148    }
149
150    // Класс для извлечения треков из Яндекс Музыки
151    class YandexMusicExtractor {
152        constructor() {
153            this.tracks = [];
154        }
155
156        async extractTracksFromPlaylist() {
157            console.log('Начинаем извлечение треков из Яндекс Музыки...');
158            
159            // Проверяем, что мы на странице плейлиста
160            if (!window.location.pathname.includes('/playlists/')) {
161                throw new Error('Откройте страницу плейлиста "Мне нравится"');
162            }
163
164            this.tracks = [];
165            let previousCount = 0;
166            let noChangeCount = 0;
167            const maxNoChange = 5;
168
169            // Прокручиваем страницу вниз, чтобы загрузить все треки
170            while (noChangeCount < maxNoChange) {
171                this.extractVisibleTracks();
172                
173                if (this.tracks.length === previousCount) {
174                    noChangeCount++;
175                } else {
176                    noChangeCount = 0;
177                    previousCount = this.tracks.length;
178                }
179
180                // Прокручиваем вниз
181                window.scrollTo(0, document.body.scrollHeight);
182                await new Promise(resolve => setTimeout(resolve, 1000));
183                
184                console.log(`Извлечено треков: ${this.tracks.length}`);
185            }
186
187            console.log(`Всего извлечено треков: ${this.tracks.length}`);
188            return this.tracks;
189        }
190
191        extractVisibleTracks() {
192            // Ищем все элементы треков на странице
193            const trackElements = document.querySelectorAll('[aria-label*="Трек"]');
194            
195            trackElements.forEach(element => {
196                try {
197                    const trackLink = element.querySelector('a[aria-label*="Трек"]');
198                    const artistLink = element.querySelector('a[aria-label*="Артист"]');
199                    
200                    if (trackLink && artistLink) {
201                        const trackName = trackLink.textContent.trim();
202                        const artistName = artistLink.textContent.trim();
203                        const trackId = trackLink.href;
204
205                        // Проверяем, что трек еще не добавлен
206                        if (!this.tracks.some(t => t.id === trackId)) {
207                            this.tracks.push({
208                                id: trackId,
209                                name: trackName,
210                                artist: artistName
211                            });
212                        }
213                    }
214                } catch (error) {
215                    console.error('Ошибка извлечения трека:', error);
216                }
217            });
218        }
219    }
220
221    // Класс для UI
222    class SyncUI {
223        constructor(spotifySync, yandexExtractor) {
224            this.spotifySync = spotifySync;
225            this.yandexExtractor = yandexExtractor;
226            this.syncButton = null;
227            this.statusDiv = null;
228            this.settingsButton = null;
229        }
230
231        createUI() {
232            // Создаем контейнер для кнопок
233            const container = document.createElement('div');
234            container.id = 'spotify-sync-container';
235            container.style.cssText = `
236                position: fixed;
237                bottom: 20px;
238                right: 20px;
239                z-index: 10000;
240                background: linear-gradient(135deg, #1DB954 0%, #1ed760 100%);
241                padding: 15px 20px;
242                border-radius: 12px;
243                box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
244                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
245                min-width: 250px;
246            `;
247
248            // Заголовок
249            const title = document.createElement('div');
250            title.textContent = 'Синхронизация со Spotify';
251            title.style.cssText = `
252                color: white;
253                font-weight: bold;
254                font-size: 14px;
255                margin-bottom: 10px;
256                text-align: center;
257            `;
258            container.appendChild(title);
259
260            // Кнопка авторизации/синхронизации
261            this.syncButton = document.createElement('button');
262            this.updateSyncButton();
263            this.syncButton.style.cssText = `
264                width: 100%;
265                padding: 10px 15px;
266                background: white;
267                color: #1DB954;
268                border: none;
269                border-radius: 8px;
270                font-weight: bold;
271                font-size: 13px;
272                cursor: pointer;
273                transition: all 0.3s;
274                margin-bottom: 8px;
275            `;
276            this.syncButton.onmouseover = () => {
277                this.syncButton.style.transform = 'scale(1.05)';
278                this.syncButton.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.2)';
279            };
280            this.syncButton.onmouseout = () => {
281                this.syncButton.style.transform = 'scale(1)';
282                this.syncButton.style.boxShadow = 'none';
283            };
284            this.syncButton.onclick = () => this.handleSyncClick();
285            container.appendChild(this.syncButton);
286
287            // Кнопка настроек
288            this.settingsButton = document.createElement('button');
289            this.settingsButton.textContent = '⚙️ Настройки Spotify';
290            this.settingsButton.style.cssText = `
291                width: 100%;
292                padding: 8px 15px;
293                background: rgba(255, 255, 255, 0.2);
294                color: white;
295                border: 1px solid rgba(255, 255, 255, 0.3);
296                border-radius: 8px;
297                font-size: 12px;
298                cursor: pointer;
299                transition: all 0.3s;
300            `;
301            this.settingsButton.onmouseover = () => {
302                this.settingsButton.style.background = 'rgba(255, 255, 255, 0.3)';
303            };
304            this.settingsButton.onmouseout = () => {
305                this.settingsButton.style.background = 'rgba(255, 255, 255, 0.2)';
306            };
307            this.settingsButton.onclick = () => this.showSettings();
308            container.appendChild(this.settingsButton);
309
310            // Статус
311            this.statusDiv = document.createElement('div');
312            this.statusDiv.style.cssText = `
313                color: white;
314                font-size: 11px;
315                margin-top: 10px;
316                text-align: center;
317                min-height: 15px;
318            `;
319            container.appendChild(this.statusDiv);
320
321            document.body.appendChild(container);
322            console.log('UI создан');
323        }
324
325        updateSyncButton() {
326            if (this.spotifySync.isAuthorized) {
327                this.syncButton.textContent = '🎵 Синхронизировать';
328            } else {
329                this.syncButton.textContent = '🔐 Войти в Spotify';
330            }
331        }
332
333        async handleSyncClick() {
334            if (!this.spotifySync.isAuthorized) {
335                this.spotifySync.authorize();
336                return;
337            }
338
339            // Начинаем синхронизацию
340            this.syncButton.disabled = true;
341            this.syncButton.textContent = '⏳ Синхронизация...';
342            this.updateStatus('Извлекаем треки из Яндекс Музыки...');
343
344            try {
345                // Извлекаем треки из Яндекс Музыки
346                const yandexTracks = await this.yandexExtractor.extractTracksFromPlaylist();
347                this.updateStatus(`Найдено ${yandexTracks.length} треков. Ищем в Spotify...`);
348
349                // Ищем треки в Spotify
350                const spotifyTrackIds = [];
351                let found = 0;
352                let notFound = 0;
353
354                for (let i = 0; i < yandexTracks.length; i++) {
355                    const track = yandexTracks[i];
356                    this.updateStatus(`Поиск в Spotify: ${i + 1}/${yandexTracks.length} (найдено: ${found})`);
357
358                    const spotifyId = await this.spotifySync.searchTrack(track.name, track.artist);
359                    if (spotifyId) {
360                        spotifyTrackIds.push(spotifyId);
361                        found++;
362                    } else {
363                        notFound++;
364                        console.log(`Не найден в Spotify: ${track.artist} - ${track.name}`);
365                    }
366
367                    // Небольшая задержка, чтобы не превысить лимиты API
368                    await new Promise(resolve => setTimeout(resolve, 100));
369                }
370
371                // Добавляем треки в Spotify
372                this.updateStatus(`Добавляем ${spotifyTrackIds.length} треков в Spotify...`);
373                await this.spotifySync.addTracksToLiked(spotifyTrackIds);
374
375                this.updateStatus(`✅ Готово! Добавлено: ${found}, не найдено: ${notFound}`);
376                this.syncButton.textContent = '✅ Синхронизировано';
377                
378                setTimeout(() => {
379                    this.syncButton.disabled = false;
380                    this.syncButton.textContent = '🎵 Синхронизировать';
381                }, 3000);
382
383            } catch (error) {
384                console.error('Ошибка синхронизации:', error);
385                this.updateStatus(`❌ Ошибка: ${error.message}`);
386                this.syncButton.disabled = false;
387                this.syncButton.textContent = '🎵 Синхронизировать';
388            }
389        }
390
391        updateStatus(message) {
392            if (this.statusDiv) {
393                this.statusDiv.textContent = message;
394                console.log(message);
395            }
396        }
397
398        showSettings() {
399            const currentClientId = SPOTIFY_CONFIG.clientId || '';
400            const newClientId = prompt(
401                'Введите Spotify Client ID:\n\n' +
402                'Чтобы получить Client ID:\n' +
403                '1. Перейдите на https://developer.spotify.com/dashboard\n' +
404                '2. Создайте новое приложение\n' +
405                '3. В настройках добавьте Redirect URI: ' + SPOTIFY_CONFIG.redirectUri + '\n' +
406                '4. Скопируйте Client ID',
407                currentClientId
408            );
409
410            if (newClientId !== null && newClientId.trim() !== '') {
411                SPOTIFY_CONFIG.clientId = newClientId.trim();
412                GM.setValue('spotify_client_id', newClientId.trim());
413                alert('Client ID сохранен! Теперь нажмите "Войти в Spotify"');
414            }
415        }
416    }
417
418    // Инициализация
419    async function init() {
420        console.log('Инициализация расширения синхронизации Яндекс Музыки со Spotify');
421
422        // Загружаем сохраненный Client ID
423        const savedClientId = await GM.getValue('spotify_client_id');
424        if (savedClientId) {
425            SPOTIFY_CONFIG.clientId = savedClientId;
426        }
427
428        // Создаем экземпляры классов
429        const spotifySync = new SpotifySync();
430        await spotifySync.init();
431
432        const yandexExtractor = new YandexMusicExtractor();
433        const ui = new SyncUI(spotifySync, yandexExtractor);
434
435        // Ждем загрузки страницы
436        if (document.readyState === 'loading') {
437            document.addEventListener('DOMContentLoaded', () => {
438                setTimeout(() => ui.createUI(), 1000);
439            });
440        } else {
441            setTimeout(() => ui.createUI(), 1000);
442        }
443
444        // Обновляем кнопку после инициализации
445        setTimeout(() => {
446            if (ui.syncButton) {
447                ui.updateSyncButton();
448            }
449        }, 1500);
450    }
451
452    // Запускаем инициализацию
453    init();
454
455})();