├── .gitignore ├── Pipfile ├── README.md ├── setup.py ├── src ├── .gitignore ├── __init__.py ├── cli.py ├── services │ ├── __init__.py │ ├── deezer.py │ ├── interfaces.py │ ├── spotify.py │ └── youtube.py └── utils.py └── versao_final.gif /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | lib/ 3 | share/ 4 | include/ 5 | bin/ 6 | lib64 7 | pyvenv.cfg 8 | .vscode/ 9 | migrator.egg-info -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | click = "*" 8 | requests = "*" 9 | rauth = "*" 10 | sqlalchemy = "*" 11 | flask = "*" 12 | flask-sqlalchemy = "*" 13 | 14 | [dev-packages] 15 | 16 | [requires] 17 | python_version = "3.9" 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Installation 3 | 4 | clone this repository and run the pip command. 5 | 6 | ```bash 7 | pip install . 8 | ``` 9 | 10 | ## Config 11 | 12 | Add the following environment variables, with the ids and secrets supplied by the streaming service. 13 | 14 | DEEZER_CLIENT_ID, DEEZER_CLIENT_SECRET 15 | 16 | SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET 17 | 18 | 19 | Spotify - https://developer.spotify.com/documentation/general/guides/app-settings/#register-your-app 20 | 21 | Deezer - https://developers.deezer.com/guidelines/getting_started 22 | 23 | ## Usage 24 | 25 | ```bash 26 | copy-playlist --from-service=spotify --to-service=deezer --playlist-name=xyz 27 | ``` 28 | ![usage](https://github.com/lohxx/migrator/blob/master/versao_final.gif) 29 | 30 | 31 | ## Supported services 32 | - Spotify 33 | - Deezer 34 | - ~~Youtube~~ 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='migrator', 5 | packages=['src'], 6 | version='0.1', 7 | install_requires=['Click', 'Flask', 'rauth', 'sqlalchemy', 'requests'], 8 | entry_points={ 9 | 'console_scripts': [ 10 | 'copy-playlist=src.cli:copy' 11 | ] 12 | } 13 | ) 14 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | tokens.pickle -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | from functools import wraps 4 | 5 | from flask import Flask, request 6 | from flask_sqlalchemy import SQLAlchemy 7 | 8 | 9 | app = Flask(__name__) 10 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 11 | app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URI'] 12 | 13 | db = SQLAlchemy(app) 14 | db.init_app(app) 15 | 16 | 17 | def save_auth_code(service): 18 | if request.args.get('code'): 19 | service.save_code_and_authenticate(request.args) 20 | 21 | 22 | @app.route('/deezer/callback') 23 | def deezer_callback(): 24 | from services.deezer import DeezerAuth 25 | save_auth_code(DeezerAuth()) 26 | return 'ok' 27 | 28 | 29 | @app.route('/spotify/callback') 30 | def spotify_callback(): 31 | from services.spotify import SpotifyAuth 32 | save_auth_code(SpotifyAuth()) 33 | return 'ok' 34 | 35 | 36 | if __name__ == '__main__': 37 | app.run(debug=True) -------------------------------------------------------------------------------- /src/cli.py: -------------------------------------------------------------------------------- 1 | import pdb 2 | import pprint 3 | import click 4 | import time 5 | import os 6 | import pickle 7 | import threading 8 | import http.server 9 | 10 | from flask_script import Manager 11 | 12 | from src.utils import PickleHandler 13 | from src import app 14 | 15 | from src.services.deezer import DeezerPlaylists 16 | from src.services.spotify import SpotifyPlaylists 17 | from src.services.youtube import YoutubeService 18 | 19 | 20 | SERVICES = { 21 | 'deezer': DeezerPlaylists, 22 | 'spotify': SpotifyPlaylists, 23 | 'youtube': YoutubeService 24 | } 25 | 26 | OPTIONS = SERVICES.keys() 27 | pickle_handler = PickleHandler('tokens.pickle') 28 | 29 | 30 | 31 | @click.group() 32 | def cli(): 33 | pass 34 | 35 | 36 | def execute_copy(origin, destination, playlist_name): 37 | start = time.time() 38 | 39 | playlist = origin.get(playlist_name) 40 | if playlist: 41 | destination.clone(playlist) 42 | 43 | end = time.time() 44 | click.echo(f'Levou um total de {end-start} para executar') 45 | 46 | 47 | @cli.command() 48 | @click.option('--name', required=True) 49 | @click.option('--to-service', type=click.Choice(OPTIONS), required=True) 50 | @click.option('--from-service', type=click.Choice(OPTIONS), required=True) 51 | def copy(from_service, to_service, name): 52 | pickle_handler.write({ 53 | 'deezer': {'code': None, 'access_token': None}, 54 | 'spotify': {'code': None, 'access_token': None, 'refresh_token': None} 55 | }) 56 | 57 | if from_service == to_service: 58 | click.echo("O serviço de origem não pode ser o mesmo serviço de destino") 59 | return 60 | 61 | origin_service = SERVICES.get(from_service)() 62 | destination_service = SERVICES.get(to_service)() 63 | 64 | execute_copy(origin_service, destination_service, name) 65 | 66 | 67 | if __name__ == '__main__': 68 | cli() 69 | -------------------------------------------------------------------------------- /src/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lohxx/migrator/c366b41ebccac688206454ffe1c77350ca0fba09/src/services/__init__.py -------------------------------------------------------------------------------- /src/services/deezer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import concurrent.futures 5 | 6 | import click 7 | import requests 8 | 9 | from rauth import OAuth2Service 10 | import sqlalchemy.orm.exc as sq_exceptions 11 | 12 | from src.utils import PickleHandler 13 | from services.interfaces import Playlist, ServiceAuth 14 | 15 | 16 | pickle_manager = PickleHandler('tokens.pickle') 17 | 18 | 19 | class DeezerAuth(ServiceAuth): 20 | 21 | def __init__(self): 22 | self.session = requests.Session() 23 | self.oauth = OAuth2Service( 24 | name='deezer', 25 | base_url='https://api.deezer.com/', 26 | client_id=os.environ['DEEZER_CLIENT_ID'], 27 | client_secret=os.environ['DEEZER_CLIENT_SECRET'], 28 | authorize_url='https://connect.deezer.com/oauth/auth.php', 29 | access_token_url='https://connect.deezer.com/oauth/access_token.php' 30 | ) 31 | 32 | self.base_url = self.oauth.base_url 33 | 34 | def _get_access_token(self, args): 35 | session = requests.Session() 36 | response = session.get(self.oauth.access_token_url, params=args) 37 | 38 | try: 39 | return response.json() 40 | except json.decoder.JSONDecodeError: 41 | click.echo(response.content) 42 | sys.exit(1) 43 | 44 | def save_code_and_authenticate(self, response): 45 | code = response.get('code', '') 46 | 47 | try: 48 | tokens = pickle_manager.read() 49 | tokens['deezer']['code'] = code 50 | pickle_manager.write(tokens) 51 | except Exception: 52 | pass 53 | 54 | self.authenticate(code) 55 | 56 | def authenticate(self, code=None): 57 | tokens = pickle_manager.read() 58 | 59 | if not tokens['deezer']['code']: 60 | self.autorization_url({ 61 | 'app_id': self.oauth.client_id, 62 | 'perms': 'manage_library, offline_access', 63 | 'redirect_uri': 'http://localhost:5000/deezer/callback' 64 | }) 65 | return 66 | 67 | if not tokens['deezer']['access_token']: 68 | # Tenta pegar o access_token 69 | response = self._get_access_token({ 70 | 'output': 'json', 71 | 'app_id': self.oauth.client_id, 72 | 'secret': self.oauth.client_secret, 73 | 'code': tokens['deezer']['code'], 74 | }) 75 | 76 | tokens['deezer']['access_token'] = response['access_token'] 77 | pickle_manager.write(tokens) 78 | 79 | return 80 | 81 | # associa o token com a session 82 | self.session.params = { 83 | 'access_token': tokens['deezer']['access_token']} 84 | 85 | 86 | class DeezerRequests: 87 | """ 88 | Realiza requisições na api do deezer. 89 | """ 90 | def __init__(self): 91 | self.oauth = DeezerAuth() 92 | 93 | # tenta conseguir a permissão do usuario 94 | # para ler e modificar as playlists 95 | self.oauth.authenticate() 96 | 97 | def paginate(self, response, params=None) -> list: 98 | """ 99 | Junta todos os resultados paginados de um request. 100 | 101 | Args: 102 | response (dict): resposta devolvida pela api do Spotify. 103 | params (dict, optional): [description]. Defaults to None. 104 | 105 | Returns: 106 | list: lista com todos os resultados do request. 107 | """ 108 | 109 | paginated_response = [] 110 | 111 | if 'data' in response: 112 | paginated_response.extend(response['data']) 113 | while len(paginated_response) < response.get('total'): 114 | if response.get('next'): 115 | response = self.oauth.session.get( 116 | response.get('next'), params=params).json() 117 | paginated_response.extend(response['data']) 118 | 119 | if not response.get('next') and response.get('prev'): 120 | break 121 | else: 122 | paginated_response = response 123 | 124 | return paginated_response 125 | 126 | def get(self, endpoint: str, querystring: dict = None): 127 | """ 128 | [summary] 129 | 130 | Args: 131 | endpoint (str): [description] 132 | querystring (dict, optional): [description]. Defaults to None. 133 | 134 | Returns: 135 | [type]: [description] 136 | """ 137 | if self.oauth.base_url not in endpoint: 138 | endpoint = self.oauth.base_url+endpoint 139 | response = self.oauth.session.get(endpoint, params=querystring) 140 | response = response.json() 141 | 142 | if response.get('error'): 143 | click.echo(response['error']['message']) 144 | sys.exit(1) 145 | 146 | return self.paginate(response) 147 | 148 | def post(self, endpoint, data=None): 149 | response = self.oauth.session.post( 150 | self.oauth.base_url+endpoint, params=data) 151 | 152 | if response.status_code not in (200, 201): 153 | raise Exception(response.text) 154 | 155 | return response.json() 156 | 157 | 158 | class DeezerPlaylists(Playlist): 159 | """ 160 | Lida com as playlists do Deezer. 161 | """ 162 | 163 | def __init__(self): 164 | self.requests = DeezerRequests() 165 | self.user = self.requests.get('user/me') 166 | 167 | def search_playlist(self, name: str) -> dict: 168 | """ 169 | Busca uma playlist. 170 | 171 | Args: 172 | name (str): nome da playlist 173 | 174 | Returns: 175 | [type]: [description] 176 | """ 177 | playlists = self.requests.get(f'/user/{self.user["id"]}/playlists') 178 | 179 | for playlists in playlists: 180 | if playlists['title'] == name: 181 | return playlists 182 | else: 183 | return {} 184 | 185 | def get_tracks(self, tracks_url: str) -> [dict]: 186 | """ 187 | Busca as musicas de uma playlist. 188 | 189 | Args: 190 | tracks_url (str): url das musicas da playlist. 191 | 192 | Returns: 193 | list[dict]: lista de dicionarios com informações 194 | sobre as musicas da playlist. 195 | """ 196 | 197 | tracks = [] 198 | playlist_tracks = self.requests.get(tracks_url) 199 | 200 | for track in playlist_tracks: 201 | tracks.append({ 202 | 'name': track['title_short'].lower(), 203 | 'album': track['album']['title'], 204 | 'artists': [track['artist']['name']] 205 | }) 206 | 207 | return tracks 208 | 209 | def get(self, name: str) -> dict: 210 | """ 211 | Busca uma playlist. 212 | 213 | Args: 214 | name (str): nome da playlist 215 | 216 | Returns: 217 | dict: dict contendo informações da playlist. 218 | """ 219 | click.echo('Procurando a playlist...') 220 | playlist = self.search_playlist(name) 221 | 222 | if playlist: 223 | click.echo('Playlist encotrada!') 224 | tracks = self.get_tracks(playlist['tracklist']) 225 | return {'playlist': name, 'tracks': tracks} 226 | 227 | click.echo('Não foi possivel achar a playlist, verifique se o nome esta correto') 228 | return {} 229 | 230 | def make_futures(self, executor, tracks): 231 | futures = {} 232 | 233 | for track in tracks: 234 | search_params = {'q': f'track:"{track["name"]}" artist:"{track["artists"][0]}" album:"{track["album"]}"&strict=on'} 235 | futures[executor.submit(self.requests.get, 'search/', search_params)] = track 236 | 237 | return futures 238 | 239 | def clone(self, playlist: dict) -> None: 240 | """ 241 | Copia uma playlist. 242 | 243 | Args: 244 | playlist (dict): playlist que vai ser copiada. 245 | """ 246 | 247 | name, tracks = playlist.values() 248 | 249 | # checa se já existe uma playlist com esse mesmo nome no serviço 250 | # para onde essa playlist vai ser copiada. 251 | playlist = self.search_playlist(name) 252 | 253 | if playlist: 254 | # copia apenas as musicas que ainda não existem lá 255 | tracks = self._diff_tracks( 256 | self.get_tracks(playlist['tracklist']), tracks) 257 | else: 258 | playlist = self.requests.post( 259 | f'user/{self.user["id"]}/playlists', {'title': name}) 260 | 261 | tracks_cache = {} 262 | 263 | with concurrent.futures.ThreadPoolExecutor() as executor: 264 | future_to_track = self.make_futures(executor, tracks) 265 | 266 | for future in concurrent.futures.as_completed(future_to_track): 267 | track = future_to_track[future] 268 | 269 | try: 270 | matches = future.result() 271 | except Exception as exc: 272 | click.echo(exc) 273 | else: 274 | for match in matches: 275 | new_track, copy_track = self.match_track( 276 | track['name'], match['title']) 277 | 278 | if copy_track and new_track not in tracks_cache: 279 | tracks_cache[new_track] = str(match['id']) 280 | 281 | if tracks_cache: 282 | response = self.requests.post( 283 | f'playlist/{playlist["id"]}/tracks', data={'songs': ','.join(tracks_cache.values())}) 284 | 285 | if response: 286 | click.echo('A playlist foi copiada com sucesso') 287 | 288 | # for track in tracks_not_found - tracks_found: 289 | # click.echo(f'A musica: {track} não foi encontrada') 290 | -------------------------------------------------------------------------------- /src/services/interfaces.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import itertools 3 | import json 4 | import pdb 5 | import re 6 | import webbrowser 7 | 8 | from abc import ABC, abstractmethod 9 | from flask import request 10 | 11 | #from src import read_pickle 12 | 13 | from src.utils import PickleHandler 14 | 15 | pickle_manager = PickleHandler('tokens.pickle') 16 | 17 | 18 | class ServiceAuth(ABC): 19 | 20 | def __init__(self): 21 | self.oauth = None 22 | self.session = None 23 | 24 | @abstractmethod 25 | def authenticate(self): 26 | pass 27 | 28 | def _get_access_token(self, args): 29 | try: 30 | session = self.oauth.get_auth_session( 31 | data=args, 32 | decoder=json.loads, 33 | ) 34 | except Exception: 35 | tokens = pickle_manager.read_pickle() 36 | session = self.oauth.get_auth_session( 37 | decoder=json.loads, 38 | data={ 39 | 'grant_type': 'refresh_token', 40 | 'refresh_token': tokens['spotify']['refresh_token'] 41 | } 42 | ) 43 | 44 | return session 45 | 46 | def autorization_url(self, args): 47 | autorization_url = self.oauth.get_authorize_url(**args) 48 | webbrowser.open(autorization_url) 49 | return self 50 | 51 | 52 | class Playlist(ABC): 53 | @abstractmethod 54 | def clone(self, playlist): 55 | pass 56 | 57 | @abstractmethod 58 | def get_tracks(self, tracks_url): 59 | pass 60 | 61 | @abstractmethod 62 | def get(self, name): 63 | pass 64 | 65 | @abstractmethod 66 | def search_playlist(self, name): 67 | pass 68 | 69 | def match_track(self, track, match): 70 | normalized_track = re.sub("[^\w\s\.,']+", "", track).lower().replace(' ', '') 71 | normalized_match = re.sub("[^\w\s\.,']+", "", match).lower().replace(' ', '') 72 | track_regexp = re.compile(f'({normalized_match})', flags=re.IGNORECASE) 73 | print(f'{normalized_match}, {normalized_track}, {track_regexp.search(normalized_track)}') 74 | if track_regexp.search(normalized_track): 75 | return normalized_track, True 76 | 77 | return track, False 78 | 79 | def _diff_tracks(self, already_existents, new_tracks): 80 | tracks_to_be_copied = [] 81 | 82 | dict_new, dict_existents = {}, {} 83 | 84 | if not already_existents: 85 | return new_tracks 86 | 87 | for existent, new in itertools.zip_longest( 88 | already_existents, new_tracks): 89 | if not all([existent, new]): 90 | continue 91 | 92 | dict_new[f'{new["name"]} - {new["artists"][0]}'.lower()] = new 93 | dict_existents[f'{existent["name"]} - {existent["artists"][0]}'.lower()] = existent 94 | 95 | for track_key in dict_new: 96 | if track_key not in dict_existents: 97 | tracks_to_be_copied.append(dict_new[track_key]) 98 | 99 | return tracks_to_be_copied 100 | -------------------------------------------------------------------------------- /src/services/spotify.py: -------------------------------------------------------------------------------- 1 | import os 2 | import concurrent.futures 3 | from typing import Union, TypeVar 4 | 5 | import click 6 | 7 | from rauth import OAuth2Service 8 | 9 | from utils import PickleHandler 10 | from services.interfaces import Playlist, ServiceAuth 11 | 12 | 13 | pickle_manager = PickleHandler('tokens.pickle') 14 | 15 | class SpotifyAuth(ServiceAuth): 16 | """ 17 | Classe responsavel pela autenticacao com o Spotify 18 | """ 19 | 20 | def __init__(self): 21 | self.session = None 22 | self.oauth = OAuth2Service( 23 | name='spotify', 24 | base_url='https://api.spotify.com/v1', 25 | client_id=os.environ['SPOTIFY_CLIENT_ID'], 26 | client_secret=os.environ['SPOTIFY_CLIENT_SECRET'], 27 | authorize_url='https://accounts.spotify.com/authorize', 28 | access_token_url='https://accounts.spotify.com/api/token' 29 | ) 30 | 31 | def save_code_and_authenticate(self, params): 32 | try: 33 | tokens = pickle_manager.read() 34 | tokens['spotify']['code'] = params['code'] 35 | pickle_manager.write(tokens) 36 | except Exception: 37 | pass 38 | 39 | self.authenticate() 40 | 41 | def authenticate(self): 42 | try: 43 | tokens = pickle_manager.read() 44 | if not tokens['spotify']['code']: 45 | self.autorization_url({ 46 | 'response_type': 'code', 47 | 'redirect_uri': 'http://localhost:5000/spotify/callback', 48 | 'scope': ', '.join([ 49 | 'playlist-modify-public', 50 | 'playlist-read-collaborative', 51 | 'playlist-read-private', 52 | 'playlist-modify-private' 53 | ]) 54 | }) 55 | else: 56 | # atualiza o access_token se a autenticação der certo 57 | self.session = self._get_access_token({ 58 | 'code': tokens['spotify']['code'], 59 | 'grant_type': 'authorization_code', 60 | 'redirect_uri': 'http://localhost:5000/spotify/callback' 61 | }) 62 | import pdb; pdb.set_trace() 63 | tokens['spotify'].update( 64 | self.session.access_token_response.json()) 65 | pickle_manager.write(tokens) 66 | except Exception: 67 | pass 68 | 69 | class SpotifyRequests: 70 | """ 71 | Realiza requisições na api do Spotify 72 | """ 73 | 74 | def __init__(self): 75 | self.oauth = SpotifyAuth() 76 | 77 | # tenta conseguir a permissão do usuario 78 | # para ler e modificar as playlists 79 | self.oauth.authenticate() 80 | 81 | @staticmethod 82 | def paginate(response: dict, queryparams: dict = None) -> Union[dict, list]: 83 | """ 84 | Junta todos os resultados paginados de um request. 85 | 86 | Args: 87 | response (dict): resposta devolvida pela api do Spotify. 88 | queryparams (dict, optional): [description]. Defaults to None. 89 | 90 | Returns: 91 | Union[dict, list]: [description] 92 | """ 93 | self = SpotifyRequests() 94 | 95 | paginated_response = [] 96 | if 'items' in response: 97 | paginated_response.extend(response['items']) 98 | 99 | while len(paginated_response) < response['total']: 100 | if response.get('next'): 101 | response = self.oauth.session.get( 102 | response.get('next'), params=queryparams).json() 103 | paginated_response.extend(response['items']) 104 | else: 105 | paginated_response = response 106 | 107 | return paginated_response 108 | 109 | def get(self, endpoint: str, queryparams: str = None) -> None: 110 | """ 111 | Busca informações. 112 | 113 | Args: 114 | endpoint (str): endpoint da requisição. 115 | queryparams (str, optional): params da url. Defaults to None. 116 | 117 | Raises: 118 | Exception: levanta exceção quando a requisição falha. 119 | 120 | Returns: 121 | [type]: retorna o objeto devolvido pelo request. 122 | """ 123 | response = self.oauth.session.get(endpoint, params=queryparams) 124 | if response.status_code != 200: 125 | raise Exception(response.text) 126 | 127 | response = response.json() 128 | 129 | paginated_response = self.paginate(response) 130 | 131 | return paginated_response 132 | 133 | def post(self, endpoint: str, data: dict) -> dict: 134 | """ 135 | Envia informações para o serviço de streaming. 136 | 137 | Args: 138 | endpoint (str): endpoint da requisição. 139 | data (dict): dados que serão enviados. 140 | 141 | Raises: 142 | Exception: levanta exceção quando a requisição falha. 143 | 144 | Returns: 145 | dict: resposta da requisição. 146 | """ 147 | response = self.oauth.session.post(endpoint, json=data) 148 | 149 | if response.status_code not in (200, 201): 150 | raise Exception(response.text) 151 | 152 | return response.json() 153 | 154 | class SpotifyPlaylists(Playlist): 155 | """ 156 | Lida com as playlists do Spotify 157 | """ 158 | 159 | def __init__(self): 160 | self.requests = SpotifyRequests() 161 | 162 | def search_playlist(self, name: str) -> dict: 163 | """ 164 | Busca a playlist no serviço de streaming. 165 | 166 | Args: 167 | name (str): nome da playlist. 168 | 169 | Returns: 170 | dict: infos da playlist. 171 | """ 172 | playlist_group = self.requests.get('v1/me/playlists') 173 | 174 | for playlist in playlist_group: 175 | if playlist['name'] == name: 176 | return playlist 177 | else: 178 | return {} 179 | 180 | def get_tracks(self, tracks_url: str) -> [dict]: 181 | """ 182 | Busca as musicas de uma playlist. 183 | 184 | Args: 185 | tracks_url (str): url das musicas da playlist. 186 | 187 | Returns: 188 | [list]: lista com as infos das musicas. 189 | """ 190 | click.echo('Buscando as musicas...') 191 | 192 | tracks = [] 193 | playlist_tracks = self.requests.get(tracks_url) 194 | 195 | for track in playlist_tracks: 196 | tracks.append({ 197 | 'name': track['track']['name'].lower(), 198 | 'album': track['track']['album']['name'], 199 | 'artists': list( 200 | map( 201 | lambda artists: artists['name'], 202 | track['track']['artists'] 203 | ) 204 | ) 205 | }) 206 | 207 | return tracks 208 | 209 | def get(self, name: str) -> dict: 210 | """ 211 | Busca a playlist. 212 | 213 | Arguments: 214 | name {str} -- nome da playlist. 215 | 216 | Returns: 217 | dict -- dicionario com as infos da playlist. 218 | """ 219 | 220 | click.echo('Procurando a playlist...') 221 | 222 | playlist = self.search_playlist(name) 223 | 224 | if playlist: 225 | click.echo('Playlist encotrada!') 226 | tracks = self.get_tracks(playlist['tracks']['href']) 227 | return {'playlist': name, 'tracks': tracks} 228 | 229 | click.echo('Não foi possivel achar a playlist, verifique se o nome esta correto') 230 | return {} 231 | 232 | def make_futures(self, executor, tracks): 233 | futures = {} 234 | 235 | for track in tracks: 236 | params = {'q': f'artist:{track["artists"][0]} track:{track["name"]} album:{track["album"]}', 'type': 'track'} 237 | futures[executor.submit(self.requests.get, '/v1/search/', params)] = track 238 | 239 | return futures 240 | 241 | def clone(self, playlist: dict) -> None: 242 | """ 243 | Copia a playlist de um serviço de streaming. 244 | 245 | Args: 246 | playlist (dict): infos da playlist que vai ser copiada. 247 | """ 248 | 249 | name, tracks = playlist.values() 250 | playlist = self.search_playlist(name) 251 | 252 | if playlist: 253 | tracks = self._diff_tracks( 254 | self.get_tracks(playlist['tracks']['href']), tracks) 255 | else: 256 | playlist = self.requests.post( 257 | '/v1/me/playlists', {"name": name, "public": True}) 258 | 259 | tracks_cache = {} 260 | 261 | with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: 262 | future_to_track = self.make_futures(executor, tracks) 263 | 264 | for future in concurrent.futures.as_completed(future_to_track): 265 | track = future_to_track[future] 266 | 267 | try: 268 | matches = future.result() 269 | except Exception as exc: 270 | click.echo(exc) 271 | else: 272 | matches_paginated = SpotifyRequests.paginate( 273 | matches.get('tracks', {})) 274 | 275 | for match in matches_paginated: 276 | normalized_track, matches = self.match_track( 277 | track['name'], match['name']) 278 | 279 | if matches and match['name'] not in tracks_cache: 280 | tracks_cache[normalized_track] = match['uri'] 281 | 282 | if tracks_cache: 283 | for chunk_ids in chunks(list(tracks_cache.values()), step=100): 284 | response = self.requests.post( 285 | f'v1/playlists/{playlist["id"]}/tracks', 286 | {'uris': chunk_ids}) 287 | 288 | if response: 289 | click.echo('A playlist foi copiada com sucesso') 290 | -------------------------------------------------------------------------------- /src/services/youtube.py: -------------------------------------------------------------------------------- 1 | # from migrator.services.auth import ServiceAuth 2 | 3 | 4 | class YoutubeService(): 5 | def __init__(self): 6 | self.oauth = None -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import os 3 | 4 | 5 | class PickleHandler: 6 | def __init__(self, picklename): 7 | self.picklename = picklename 8 | 9 | def read(self): 10 | data = {} 11 | 12 | with open(self.picklename, 'rb') as obj: 13 | data = pickle.loads(obj.read()) 14 | 15 | return data 16 | 17 | def write(self, data): 18 | with open(self.picklename, 'wb') as f: 19 | pickle.dump(data, f, pickle.HIGHEST_PROTOCOL) 20 | 21 | 22 | def chunks(lista, step=100): 23 | for i in range(0, len(lista), step): 24 | yield lista[i: i+step] 25 | -------------------------------------------------------------------------------- /versao_final.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lohxx/migrator/c366b41ebccac688206454ffe1c77350ca0fba09/versao_final.gif --------------------------------------------------------------------------------