Синхронизирует вашу коллекцию треков из Яндекс Музыки с лайкнутыми треками в 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})();