From 26af6a42a582fcbd7fe05bca2db1491b6da9e6c0 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 21 Mar 2023 14:46:40 +0100 Subject: [PATCH 01/33] Add spotipy as requirement --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 72e94c5..e32967e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ ytmusicapi~=0.25 pandas~=1.4 -python-dotenv~=1.0 \ No newline at end of file +python-dotenv~=1.0 +spotipy~=2.22 \ No newline at end of file From 8d9c8c6d5aee2eca726badf51dc7faaeeefa3c06 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 21 Mar 2023 15:53:40 +0100 Subject: [PATCH 02/33] add chardet as requirement --- requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e32967e..6e48ab3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ ytmusicapi~=0.25 pandas~=1.4 python-dotenv~=1.0 -spotipy~=2.22 \ No newline at end of file +chardet~=5.1 +spotipy~=2.22 + From 034d2cbfb32c1ae3a82d6ddf4fe64d24b05e6a19 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 21 Mar 2023 15:59:12 +0100 Subject: [PATCH 03/33] add cli menu --- requirements.txt | 1 + src/{utils => cli}/bcolors.py | 0 src/cli/menu.py | 16 ++++++++++++++++ src/cli/operation.py | 7 +++++++ src/main.py | 23 ++++++++++++++++++++++- src/yt_music.py | 2 +- 6 files changed, 47 insertions(+), 2 deletions(-) rename src/{utils => cli}/bcolors.py (100%) create mode 100644 src/cli/menu.py create mode 100644 src/cli/operation.py diff --git a/requirements.txt b/requirements.txt index 6e48ab3..bbd4ff2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ pandas~=1.4 python-dotenv~=1.0 chardet~=5.1 spotipy~=2.22 +inquirer~=3.1 diff --git a/src/utils/bcolors.py b/src/cli/bcolors.py similarity index 100% rename from src/utils/bcolors.py rename to src/cli/bcolors.py diff --git a/src/cli/menu.py b/src/cli/menu.py new file mode 100644 index 0000000..7c05a75 --- /dev/null +++ b/src/cli/menu.py @@ -0,0 +1,16 @@ +from pprint import pprint +import inquirer +from cli.operation import Operation + + +def menu() -> Operation: + questions = [ + inquirer.List( + "operation", + message="Select an operation:", + choices=[op.value for op in Operation], + ), + ] + + answers = inquirer.prompt(questions) + return Operation(answers["operation"]) diff --git a/src/cli/operation.py b/src/cli/operation.py new file mode 100644 index 0000000..eacccfd --- /dev/null +++ b/src/cli/operation.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class Operation(Enum): + GET_SPOTIFY_PLAYLISTS = 'Get Spotify playlists' + SYNC_YOUTUBE_PLAYLISTS_FROM_CSV = 'Create YouTube playlist from CSV files' + SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY = 'Sync YouTube Music playlists from Spotify' diff --git a/src/main.py b/src/main.py index 74fe853..fb6404c 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,7 @@ +from cli.bcolors import bcolors from yt_music import setup_YTMusic, sync_playlists +from cli.menu import menu +from cli.operation import Operation if __name__ == "__main__": @@ -6,4 +9,22 @@ playlists_dir = 'resources/playlists/' - sync_playlists(ytmusic, playlists_dir) + operation: Operation = menu() + + match operation: + case Operation.GET_SPOTIFY_PLAYLISTS: + print(f"{bcolors.OKBLUE}Getting Spotify playlists...{bcolors.ENDC}") + print(f"{bcolors.WARNING}Not implemented yet...{bcolors.ENDC}") + + case Operation.SYNC_YOUTUBE_PLAYLISTS_FROM_CSV: + print( + f"{bcolors.OKBLUE}Syncing YouTube playlists from CSV files...{bcolors.ENDC}") + sync_playlists(ytmusic, playlists_dir) + + case Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY: + print( + f"{bcolors.OKBLUE}Syncing YouTube playlists with Spotify...{bcolors.ENDC}") + print(f"{bcolors.WARNING}Not implemented yet...{bcolors.ENDC}") + + case _: + print("Invalid operation") diff --git a/src/yt_music.py b/src/yt_music.py index a8fd2ab..6c17742 100644 --- a/src/yt_music.py +++ b/src/yt_music.py @@ -2,7 +2,7 @@ import pandas as pd import os from song import Song -from utils.bcolors import bcolors +from cli.bcolors import bcolors from utils.string_utils import title_case from dotenv import load_dotenv From 8f548dd6ed78f672b4ee7542ebd6cbe8e0ec32a0 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 21 Mar 2023 16:24:59 +0100 Subject: [PATCH 04/33] add spotipy env variables --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 49426d0..5ea7e52 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ +SPOTIPY_CLIENT_ID='paste your Spotify client id here' +SPOTIPY_CLIENT_SECRET='paste your Spotify client secret here' +SPOTIPY_REDIRECT_URI='paste your Spotify redirect uri here. E.g. http://localhost:8888/callback/' HEADER_RAW='Paste your raw header here' \ No newline at end of file From 9d353a4af42ee738e8d020216aaca54e5a80791d Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 21 Mar 2023 17:48:25 +0100 Subject: [PATCH 05/33] add spotify authentication --- src/spotify.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/spotify.py diff --git a/src/spotify.py b/src/spotify.py new file mode 100644 index 0000000..d505688 --- /dev/null +++ b/src/spotify.py @@ -0,0 +1,29 @@ +import os +import json +import time +import spotipy +from spotipy.client import Spotify +from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOAuth +from dotenv import load_dotenv + + +def setup_Spotipy() -> Spotify: + load_dotenv() + client_id: str = os.getenv('SPOTIPY_CLIENT_ID') + client_secret: str = os.getenv('SPOTIPY_CLIENT_SECRET') + redirect_uri: str = os.getenv('SPOTIPY_REDIRECT_URI') + + # maybe add playlist-modify-public playlist-modify-private + scope = "playlist-read-private playlist-read-collaborative user-library-read" + + # spotify authentication + oauth = spotipy.SpotifyOAuth(client_id, client_secret, redirect_uri, scope) + return spotipy.Spotify(auth_manager=oauth) + + +if __name__ == "__main__": + spotify: Spotify = setup_Spotipy() + + playlists = spotify.current_user_playlists() + for idx, playlist in enumerate(playlists['items']): + print(playlist['name']) From d6c686a06f64ee7b8c20905d5a864c7bf3bfd9f2 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 21 Mar 2023 21:07:32 +0100 Subject: [PATCH 06/33] add spotify.py which implements get_playlists --- src/playlist.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++ src/song.py | 29 +++++++++++++++++++---- src/spotify.py | 29 +++++++++++++++++------ 3 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 src/playlist.py diff --git a/src/playlist.py b/src/playlist.py new file mode 100644 index 0000000..781dbaa --- /dev/null +++ b/src/playlist.py @@ -0,0 +1,61 @@ +from __future__ import annotations +from dataclasses import dataclass +from song import Song +from spotipy.client import Spotify +from cli.bcolors import bcolors + + +def get_songs_by_playlist_id(playlist_id: str, total_songs: int, spotify: Spotify) -> list[Song]: + if total_songs == 0: + return [] + + elif total_songs <= 100: + songs_json = spotify.playlist_items( + playlist_id, limit=total_songs)['items'] + + else: + # there is a limit of 100 songs per request + offset = 0 + songs_json = [] + while offset < total_songs: + songs_json_temp = spotify.playlist_items( + playlist_id, limit=100, offset=offset) + songs_json.extend(songs_json_temp["items"]) + offset += 100 + + songs = [] + for song in songs_json: + songs.append(Song.get_song_from_json(song["track"])) + + if (total_songs != len(songs)): + print(f"{bcolors.WARNING}WARNING: Playlist ID {playlist_id}: {total_songs} songs were expected, but only {len(songs)} were found{bcolors.ENDC}") + + return songs + + +@ dataclass +class Playlist: + + def __init__(self, name: str, id: str, image: str, description: str, songs: list[Song] = []): + self.name: str = name + self.id: str = id + self.image: str = image + self.description: str = description + self.songs: list[Song] = songs + + @ classmethod + def get_playlist_from_json(cls, args, spotify: Spotify) -> Playlist: + json = args[1] + + name = json["name"] + id = json["id"] + image = json["images"][0]["url"] if len(json["images"]) > 0 else None + description = json["description"] + total_songs = json["tracks"]["total"] + songs = get_songs_by_playlist_id(id, total_songs, spotify) + + playlist = cls(name, id, image, description, songs) + return playlist + + def __str__(self): + return "Playlist{name=" + self.name + ", id=" + self.id + ", image=" + self.image + ", description=" + self.description + "}" diff --git a/src/song.py b/src/song.py index 785780e..9ef8ada 100644 --- a/src/song.py +++ b/src/song.py @@ -4,6 +4,10 @@ from ytmusicapi import YTMusic +def join_artists_names(artists: list) -> str: + return ", ".join(map(lambda artist: artist["name"] if type(artist) is dict else artist, artists)) + + @dataclass class Song: @@ -30,6 +34,26 @@ def get_song_from_csv_row(cls, args) -> Song: duration, is_explicit, year) return song + @classmethod + def get_song_from_json(cls, args) -> Song: + track = args["name"] + artist = join_artists_names(map(lambda a: a["name"], args["artists"])) + album = args["album"]["name"] + duration = args["duration_ms"] / \ + 1000 if args["duration_ms"] is not None else None + is_explicit = args["explicit"] + + match args["album"]["release_date_precision"]: + case "day" | "month": + year = args["album"]["release_date"].split("-")[0] + case "year": + year = args["album"]["release_date"] + case _: + year = None + + song = cls(track, artist, album, duration, is_explicit, year) + return song + def get_search_query(self) -> str: return self.artist + " - " + self.track + " (" + self.album + ")" @@ -66,13 +90,10 @@ def is_similar_track_name(self, track: str, is_detailed_search: bool = True) -> def is_similar_artist(self, artists: str, is_detailed_search: bool = True) -> bool: self_artist = self.artist if is_detailed_search else self.artist.split(",")[ 0] - other_artist = self.join_artists_names( + other_artist = join_artists_names( artists) if is_detailed_search else artists[0]["name"] return string_similarity(self_artist, other_artist) > 0.8 - def join_artists_names(self, artists: list) -> str: - return ", ".join(map(lambda artist: artist["name"] if type(artist) is dict else artist, artists)) - def is_similar_album(self, album: str, is_detailed_search: bool = True) -> bool: return string_similarity(remove_parenthesis_content(self.album), remove_parenthesis_content(album)) > 0.5 if is_detailed_search else True diff --git a/src/spotify.py b/src/spotify.py index d505688..725bb17 100644 --- a/src/spotify.py +++ b/src/spotify.py @@ -1,10 +1,11 @@ import os -import json -import time import spotipy from spotipy.client import Spotify -from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOAuth from dotenv import load_dotenv +from cli.bcolors import bcolors + + +from playlist import Playlist def setup_Spotipy() -> Spotify: @@ -21,9 +22,23 @@ def setup_Spotipy() -> Spotify: return spotipy.Spotify(auth_manager=oauth) +def get_playlists(spotify: Spotify) -> list[Playlist]: + current_user = spotify.current_user() + print( + f"{bcolors.HEADER}Searching for {current_user['display_name']}'s playlists...{bcolors.ENDC}") + + playlists_json = spotify.current_user_playlists() + print( + f"{bcolors.OKBLUE}Found {playlists_json['total']} playlist(s){bcolors.ENDC}") + playlists = [] + for playlist_json in enumerate(playlists_json['items']): + playlist = Playlist.get_playlist_from_json(playlist_json, spotify) + print(f"Found playlist {playlist.name} ({len(playlist.songs)} songs)") + playlists.append(playlist) + + return playlists + + if __name__ == "__main__": spotify: Spotify = setup_Spotipy() - - playlists = spotify.current_user_playlists() - for idx, playlist in enumerate(playlists['items']): - print(playlist['name']) + playlists = get_playlists(spotify) From 0faf3a4cefa7d0b598efe147f2375122034d1029 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 22 Mar 2023 15:48:59 +0100 Subject: [PATCH 07/33] implement sync Spotify -> YT and disable other operations --- header_raw.txt | 36 ++++++++++++ src/cli/operation.py | 3 +- src/main.py | 23 +++++--- src/playlist.py | 29 +++++----- src/song.py | 135 ++++++++++++++++++++++++------------------- src/spotify.py | 22 ++++--- src/yt_music.py | 95 ++++++++++++++++++++---------- 7 files changed, 213 insertions(+), 130 deletions(-) create mode 100644 header_raw.txt diff --git a/header_raw.txt b/header_raw.txt new file mode 100644 index 0000000..845b7a4 --- /dev/null +++ b/header_raw.txt @@ -0,0 +1,36 @@ +accept: */* +accept-encoding: gzip, deflate, br +accept-language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7 +authorization: SAPISIDHASH 1679311387_dfff4144ded224a54edf530d38964eb140b2061f +content-length: 2035 +content-type: application/json +cookie: VISITOR_INFO1_LIVE=ynhkxBlC3GY; DEVICE_INFO=ChxOekU0TnpZM016RXdPVFV6T1RnMk16SXdOZz09EJz9/p0GGJz9/p0G; PREF=tz=Europe.Rome&f5=30000&f6=40000000&f7=100; YSC=dMhXZcuXWOQ; _gcl_au=1.1.1666305805.1679311190; SID=UggYhjeAjFC2ZxpBtG_aCveQ2Jy42NhFFr0OnRDebs-F4kFwbUHI38GSrVnOSZ09GJxD0w.; __Secure-1PSID=UggYhjeAjFC2ZxpBtG_aCveQ2Jy42NhFFr0OnRDebs-F4kFwKcsW5KmMmeg6q742w7WaUA.; __Secure-3PSID=UggYhjeAjFC2ZxpBtG_aCveQ2Jy42NhFFr0OnRDebs-F4kFwmk1S65kjde-f5FDx_ouElg.; HSID=ASXi3Ge0ewWXm-VMr; SSID=A4v1DnshKFwgKhYU0; APISID=mTFGjWb4BbfvjqN-/AWJ_bKGK7XbCF5n6F; SAPISID=TPMRXnKSgDFnsNPj/AHn0QC9CU3LdRoXzv; __Secure-1PAPISID=TPMRXnKSgDFnsNPj/AHn0QC9CU3LdRoXzv; __Secure-3PAPISID=TPMRXnKSgDFnsNPj/AHn0QC9CU3LdRoXzv; LOGIN_INFO=AFmmF2swRQIgUo7NcKNUHK3f1riWkf3S8ZFoSBbo8P74B--l7ah47WMCIQCfshm0V-EzJ3TBjckDi0_Ia32xAuuOYiwirx8AMIRN7Q:QUQ3MjNmeWFtbkxsTEI5T1lGTjVCeWE0NklXcTUxMDUxOWVpY3ZiZ1I0SUwxQ25Hdm1lYmVLMlJ2WThmSEpqV2tWWmNGRDVNTVVGV3dkb0xtT3ZmSFRCMENvQjJOTDZLRnFod2hDNEFZcnJuNEJuSXpYdTV2QTd2cWxZX2ZHSUZhbEJRMjdZTE9LTnJfaTJjVmlpMUJvVkFfVkNzRG9UZzBB; SIDCC=AFvIBn8mheRUPM2os_P9cfKuphOXsgJLxemVM8ax0YnO60cVSz_j2Qdq_a0lA0mfbfBn0Owe0xY; __Secure-1PSIDCC=AFvIBn-d1QadKd9L-1yqAR01R8QSe0S9x7Fq9BSKjfy5pHAh01KwFtRxoGl4nHRXnKHtqRkNQdCH; __Secure-3PSIDCC=AFvIBn-EzpHbVgN8iSfND7FXlA8HspRF8NObOOZD10w2GuJ2hEoUiFwVdgXFI-2Ixf6aXQCX5Ho1 +dnt: 1 +origin: https://music.youtube.com +referer: https://music.youtube.com/ +sec-ch-ua: "Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111" +sec-ch-ua-arch: "x86" +sec-ch-ua-bitness: "64" +sec-ch-ua-full-version: "111.0.5563.65" +sec-ch-ua-full-version-list: "Google Chrome";v="111.0.5563.65", "Not(A:Brand";v="8.0.0.0", "Chromium";v="111.0.5563.65" +sec-ch-ua-mobile: ?0 +sec-ch-ua-model +sec-ch-ua-platform: "Windows" +sec-ch-ua-platform-version: "10.0.0" +sec-ch-ua-wow64: ?0 +sec-fetch-dest: empty +sec-fetch-mode: same-origin +sec-fetch-site: same-origin +user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 +x-client-data: CJW2yQEIorbJAQjBtskBCKmdygEI3dPKAQjC9soBCJahywEIjozNAQiXls0BCKiWzQEI4pfNAQjkl80BCM2YzQEItJrNAQjom80BCMuczQE= +Decoded: +message ClientVariations { + // Active client experiment variation IDs. + repeated int32 variation_id = [3300117, 3300130, 3300161, 3313321, 3320285, 3324738, 3330198, 3360270, 3361559, 3361576, 3361762, 3361764, 3361869, 3362100, 3362280, 3362379]; +} +x-goog-authuser: 0 +x-goog-visitor-id: Cgt5bmhreEJsQzNHWSi6guGgBg%3D%3D +x-origin: https://music.youtube.com +x-youtube-bootstrap-logged-in: true +x-youtube-client-name: 67 +x-youtube-client-version: 1.20230313.00.00 \ No newline at end of file diff --git a/src/cli/operation.py b/src/cli/operation.py index eacccfd..fc221c8 100644 --- a/src/cli/operation.py +++ b/src/cli/operation.py @@ -2,6 +2,5 @@ class Operation(Enum): - GET_SPOTIFY_PLAYLISTS = 'Get Spotify playlists' - SYNC_YOUTUBE_PLAYLISTS_FROM_CSV = 'Create YouTube playlist from CSV files' SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY = 'Sync YouTube Music playlists from Spotify' + SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE = 'Sync Spotify playlists from YouTube Music' diff --git a/src/main.py b/src/main.py index fb6404c..e8071db 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,12 @@ from cli.bcolors import bcolors -from yt_music import setup_YTMusic, sync_playlists +from spotify import get_playlists, setup_Spotipy +from yt_music import setup_YTMusic, sync_playlist from cli.menu import menu from cli.operation import Operation if __name__ == "__main__": + spotify = setup_Spotipy() ytmusic = setup_YTMusic() playlists_dir = 'resources/playlists/' @@ -12,18 +14,21 @@ operation: Operation = menu() match operation: - case Operation.GET_SPOTIFY_PLAYLISTS: - print(f"{bcolors.OKBLUE}Getting Spotify playlists...{bcolors.ENDC}") - print(f"{bcolors.WARNING}Not implemented yet...{bcolors.ENDC}") - case Operation.SYNC_YOUTUBE_PLAYLISTS_FROM_CSV: + case Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY: print( - f"{bcolors.OKBLUE}Syncing YouTube playlists from CSV files...{bcolors.ENDC}") - sync_playlists(ytmusic, playlists_dir) + f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}{Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY.value}...{bcolors.ENDC}") + playlists = get_playlists(spotify) - case Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY: + for playlist in playlists: + sync_playlist(playlist, ytmusic) + + print( + f"{bcolors.OKGREEN}{len(playlists)} have been synced to YT Music{bcolors.ENDC}") + + case Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE: print( - f"{bcolors.OKBLUE}Syncing YouTube playlists with Spotify...{bcolors.ENDC}") + f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}{Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE.value}...{bcolors.ENDC}") print(f"{bcolors.WARNING}Not implemented yet...{bcolors.ENDC}") case _: diff --git a/src/playlist.py b/src/playlist.py index 781dbaa..5e7047e 100644 --- a/src/playlist.py +++ b/src/playlist.py @@ -1,10 +1,21 @@ from __future__ import annotations from dataclasses import dataclass -from song import Song +from song import Song, get_song_from_spotify_json from spotipy.client import Spotify from cli.bcolors import bcolors +def get_playlist_from_spotify(json, spotify: Spotify) -> Playlist: + name = json["name"] + id = json["id"] + image = json["images"][0]["url"] if len(json["images"]) > 0 else None + description = json["description"] + total_songs = json["tracks"]["total"] + songs = get_songs_by_playlist_id(id, total_songs, spotify) + + return Playlist(name, id, image, description, songs) + + def get_songs_by_playlist_id(playlist_id: str, total_songs: int, spotify: Spotify) -> list[Song]: if total_songs == 0: return [] @@ -25,7 +36,7 @@ def get_songs_by_playlist_id(playlist_id: str, total_songs: int, spotify: Spotif songs = [] for song in songs_json: - songs.append(Song.get_song_from_json(song["track"])) + songs.append(get_song_from_spotify_json(song["track"])) if (total_songs != len(songs)): print(f"{bcolors.WARNING}WARNING: Playlist ID {playlist_id}: {total_songs} songs were expected, but only {len(songs)} were found{bcolors.ENDC}") @@ -43,19 +54,5 @@ def __init__(self, name: str, id: str, image: str, description: str, songs: list self.description: str = description self.songs: list[Song] = songs - @ classmethod - def get_playlist_from_json(cls, args, spotify: Spotify) -> Playlist: - json = args[1] - - name = json["name"] - id = json["id"] - image = json["images"][0]["url"] if len(json["images"]) > 0 else None - description = json["description"] - total_songs = json["tracks"]["total"] - songs = get_songs_by_playlist_id(id, total_songs, spotify) - - playlist = cls(name, id, image, description, songs) - return playlist - def __str__(self): return "Playlist{name=" + self.name + ", id=" + self.id + ", image=" + self.image + ", description=" + self.description + "}" diff --git a/src/song.py b/src/song.py index 9ef8ada..5cab6bc 100644 --- a/src/song.py +++ b/src/song.py @@ -4,25 +4,58 @@ from ytmusicapi import YTMusic -def join_artists_names(artists: list) -> str: - return ", ".join(map(lambda artist: artist["name"] if type(artist) is dict else artist, artists)) +def join_artists_names(artists: list[str]) -> str: + return ", ".join(artists) + + +def get_song_from_ytmusic_json(args) -> Song: + title = args["title"] + artist = list(map(lambda a: a["name"], args["artists"])) + album = args["album"]["name"] if args["album"] is not None else None + duration = args["duration_seconds"] + is_explicit = args["isExplicit"] + year = args["year"] + id = args["videoId"] + + return Song(title, artist, album, duration, is_explicit, year, id) + + +def get_song_from_spotify_json(args) -> Song: + title = args["name"] + artist = list(map(lambda a: a["name"], args["artists"])) + album = args["album"]["name"] + duration = args["duration_ms"] / \ + 1000 if args["duration_ms"] is not None else None + is_explicit = args["explicit"] + + match args["album"]["release_date_precision"]: + case "day" | "month": + year = args["album"]["release_date"].split("-")[0] + case "year": + year = args["album"]["release_date"] + case _: + year = None + + id = args["id"] + + return Song(title, artist, album, duration, is_explicit, year, id) @dataclass class Song: - def __init__(self, track: str, artist: str, album: str, duration: int = None, is_explicit: bool = False, year: int = None): - self.track: str = track - self.artist: str = artist + def __init__(self, title: str, artists: list[str], album: str, duration: int = None, is_explicit: bool = False, year: int = None, id: str = None): + self.id: str = id + self.title: str = title + self.artists: list[str] = artists self.album: str = album - self.duration: int = duration / \ - 1000 if duration is not None else None + self.duration: int = duration self.is_explicit: bool = is_explicit self.year: int = year @classmethod def get_song_from_csv_row(cls, args) -> Song: - track = args["Track Name"] + title = args["Track Name"] artist = args["Artist Name(s)"] album = args["Album Name"] duration = args["Track Duration (ms)"] @@ -30,83 +63,63 @@ def get_song_from_csv_row(cls, args) -> Song: year = args["Album Release Date"].split( "-")[0] if args["Album Release Date"] is not None else None - song = cls(track, artist, album, + song = cls(title, artist, album, duration, is_explicit, year) return song - @classmethod - def get_song_from_json(cls, args) -> Song: - track = args["name"] - artist = join_artists_names(map(lambda a: a["name"], args["artists"])) - album = args["album"]["name"] - duration = args["duration_ms"] / \ - 1000 if args["duration_ms"] is not None else None - is_explicit = args["explicit"] - - match args["album"]["release_date_precision"]: - case "day" | "month": - year = args["album"]["release_date"].split("-")[0] - case "year": - year = args["album"]["release_date"] - case _: - year = None - - song = cls(track, artist, album, duration, is_explicit, year) - return song - def get_search_query(self) -> str: - return self.artist + " - " + self.track + " (" + self.album + ")" + return join_artists_names(self.artists) + " - " + self.title + " (" + self.album + ")" def get_search_result(self, ytmusic: YTMusic) -> Song: search_results = ytmusic.search( query=self.get_search_query(), filter="songs") best_result = next( - (res for res in search_results if self.is_result_similar(res, True)), None) + (get_song_from_ytmusic_json(res) for res in search_results if self.is_similar(get_song_from_ytmusic_json(res), True)), None) if (best_result is None): best_result = next( - (res for res in search_results if self.is_result_similar(res, False)), None) + (get_song_from_ytmusic_json(res) for res in search_results if self.is_similar(get_song_from_ytmusic_json(res), False)), None) return best_result - def is_result_similar(self, result: Song, is_detailed_search: bool = True) -> bool: - return (self.is_similar_track_name(result["title"], is_detailed_search) and - self.is_similar_artist(result["artists"], is_detailed_search) and - self.is_similar_album(result["album"]["name"], is_detailed_search) and - self.is_live() == self.is_live(result["title"]) and - self.is_similar_duration(result["duration"]) and - self.is_explicit == result["isExplicit"] and - (self.year == result["year"] if self.year is not None and result["year"] is not None else True)) - - def is_similar_track_name(self, track: str, is_detailed_search: bool = True) -> bool: - self_track = self.track if is_detailed_search else remove_parenthesis_content( - get_string_before_dash(self.track)) - other_track = track if is_detailed_search else remove_parenthesis_content( - get_string_before_dash(track)) - - return string_similarity(self_track, other_track) > 0.8 - - def is_similar_artist(self, artists: str, is_detailed_search: bool = True) -> bool: - self_artist = self.artist if is_detailed_search else self.artist.split(",")[ - 0] - other_artist = join_artists_names( - artists) if is_detailed_search else artists[0]["name"] - return string_similarity(self_artist, other_artist) > 0.8 + def is_similar(self, other: Song, is_detailed_search: bool = True) -> bool: + return (self.is_similar_title(other.title, is_detailed_search) and + self.is_similar_artists(other.artists, is_detailed_search) and + self.is_similar_album(other.album, is_detailed_search) and + self.is_live() == other.is_live() and + self.is_similar_duration(other.duration) and + self.is_explicit == other.is_explicit and + (self.year == other.year if self.year is not None and other.year is not None else True)) + + def is_similar_title(self, title: str, is_detailed_search: bool = True) -> bool: + self_title = self.title if is_detailed_search else remove_parenthesis_content( + get_string_before_dash(self.title)) + other_title = title if is_detailed_search else remove_parenthesis_content( + get_string_before_dash(title)) + + return string_similarity(self_title, other_title) > 0.8 + + def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True) -> bool: + if (is_detailed_search): + if len(self.artists) != len(artists): + return False + else: + return all(map(lambda a: string_similarity(a[0], a[1]) > 0.8, zip(self.artists, artists))) + else: + return self.artists[0] == artists[0] def is_similar_album(self, album: str, is_detailed_search: bool = True) -> bool: return string_similarity(remove_parenthesis_content(self.album), remove_parenthesis_content(album)) > 0.5 if is_detailed_search else True - def is_live(self, track: str = None) -> bool: - return "live" in track.lower() if track is not None else "live" in self.track.lower() + def is_live(self, title: str = None) -> bool: + return "live" in title.lower() if title is not None else "live" in self.title.lower() - def is_similar_duration(self, duration_str: str) -> bool: + def is_similar_duration(self, duration: int) -> bool: """ Returns true if the duration of the song is within 10 seconds of the result """ - duration_split = duration_str.split(":") - duration = int(duration_split[0])*60 + int(duration_split[1]) return abs(self.duration - duration) < 10 if self.duration is not None and duration is not None else True def __str__(self): - return self.artist + " - " + self.track + " (" + self.album + ")" + return join_artists_names(self.artists) + " - " + self.title + " (" + self.album + ")" diff --git a/src/spotify.py b/src/spotify.py index 725bb17..5a0ec1d 100644 --- a/src/spotify.py +++ b/src/spotify.py @@ -5,7 +5,7 @@ from cli.bcolors import bcolors -from playlist import Playlist +from playlist import Playlist, get_playlist_from_spotify def setup_Spotipy() -> Spotify: @@ -25,20 +25,18 @@ def setup_Spotipy() -> Spotify: def get_playlists(spotify: Spotify) -> list[Playlist]: current_user = spotify.current_user() print( - f"{bcolors.HEADER}Searching for {current_user['display_name']}'s playlists...{bcolors.ENDC}") + f"{bcolors.OKCYAN}Searching for {current_user['display_name']}'s playlists...{bcolors.ENDC}") playlists_json = spotify.current_user_playlists() - print( - f"{bcolors.OKBLUE}Found {playlists_json['total']} playlist(s){bcolors.ENDC}") + number_of_playlists = playlists_json['total'] + print(f"{bcolors.BOLD}Found {number_of_playlists} playlist(s):{bcolors.ENDC}") + + right_justify = len(str(number_of_playlists)) playlists = [] - for playlist_json in enumerate(playlists_json['items']): - playlist = Playlist.get_playlist_from_json(playlist_json, spotify) - print(f"Found playlist {playlist.name} ({len(playlist.songs)} songs)") + for idx, playlist_json in enumerate(playlists_json['items']): + playlist = get_playlist_from_spotify(playlist_json, spotify) + print( + f"{str(idx + 1).rjust(right_justify)}. {playlist.name} ({len(playlist.songs)} songs)") playlists.append(playlist) return playlists - - -if __name__ == "__main__": - spotify: Spotify = setup_Spotipy() - playlists = get_playlists(spotify) diff --git a/src/yt_music.py b/src/yt_music.py index 6c17742..2c21c32 100644 --- a/src/yt_music.py +++ b/src/yt_music.py @@ -1,6 +1,7 @@ from ytmusicapi import YTMusic import pandas as pd import os +from playlist import Playlist from song import Song from cli.bcolors import bcolors from utils.string_utils import title_case @@ -15,7 +16,7 @@ def read_file(path: str) -> str: def setup_YTMusic() -> YTMusic: load_dotenv() - header_raw = os.getenv('HEADER_RAW') + header_raw = read_file("./header_raw.txt") header_json_path = "./resources/headers_auth.json" YTMusic.setup(filepath=header_json_path, headers_raw=header_raw) ytmusic = YTMusic(header_json_path) @@ -23,46 +24,80 @@ def setup_YTMusic() -> YTMusic: return ytmusic -def search_matches(df: pd.DataFrame, ytmusic: YTMusic) -> list: - songs_to_sync = [] - for index, row in df.iterrows(): - song = Song.get_song_from_csv_row(row) +# def search_matches(df: pd.DataFrame, ytmusic: YTMusic) -> list: +# songs_to_sync = [] +# for index, row in df.iterrows(): +# song = Song.get_song_from_csv_row(row) + +# print("Looking for matches for " + str(song)) +# search_result = song.get_search_result(ytmusic) +# if (search_result is None): +# print( +# f"{bcolors.WARNING}WARNING: No match found for track nr. {str(index + 1)}: {str(song)}{bcolors.ENDC}") +# else: +# songs_to_sync.append(search_result) + +# return songs_to_sync + - print("Looking for matches for " + str(song)) - search_result = song.get_search_result(ytmusic) +def search_matches(songs: list[Song], ytmusic: YTMusic) -> list[Song]: + songs_to_sync = [] + for idx, song in enumerate(songs): + print("Looking for a match for " + str(song)) + search_result: Song = song.get_search_result(ytmusic) if (search_result is None): print( - f"{bcolors.WARNING}WARNING: No match found for track nr. {str(index + 1)}: {str(song)}{bcolors.ENDC}") + f"{bcolors.WARNING}WARNING: No match found for track nr. {str(idx + 1)}: {str(song)}{bcolors.ENDC}") else: songs_to_sync.append(search_result) return songs_to_sync -def sync_playlists(ytmusic: YTMusic, playlists_dir: str) -> None: - for filename in os.scandir(playlists_dir): - if not filename.is_file() or not filename.name.endswith('.csv'): - print( - f"{bcolors.FAIL}Warning: Skipping {filename.name}...{bcolors.ENDC}") - continue - else: - playlist_name = title_case(filename.name[:-4]) - print( - f"{bcolors.HEADER}Reading {filename.name}...{bcolors.ENDC}") +def sync_playlist(playlist: Playlist, ytmusic: YTMusic): - df = pd.read_csv(playlists_dir + filename.name).reset_index() - songs_to_sync = search_matches(df, ytmusic) + print(f"{bcolors.BOLD}\nSearching matches for songs in playlist \"{playlist.name}\"{bcolors.ENDC}") + # for each song in the Spotify playlist, search for a match on YouTube Music + songs_to_sync = search_matches(playlist.songs, ytmusic) - print( - f"{bcolors.OKCYAN}{len(songs_to_sync)}/{len(df.index)} result(s) have been found for the playlist {playlist_name}{bcolors.ENDC}") + print( + f"Creating playlist \"{playlist.name}\" in your YouTube Music account...") + playlistId = ytmusic.create_playlist( + playlist.name, playlist.description) - print("Creating playlist " + playlist_name + "...") - playlistId = ytmusic.create_playlist( - playlist_name, "Playlist description") + print( + f"Syncing playlist \"{playlist.name}\" in your YouTube Music account...") + ytmusic.add_playlist_items(playlistId, map( + lambda song: song.id, songs_to_sync)) - print("Syncing playlist " + playlist_name + "...") - ytmusic.add_playlist_items(playlistId, map( - lambda song: song["videoId"], songs_to_sync)) + print( + f"{bcolors.OKBLUE}The \"{playlist.name}\" playlist has been synced to your YouTube Music account!{bcolors.ENDC}") - print( - f"{bcolors.OKGREEN}The {playlist_name} playlist has been synced!{bcolors.ENDC}") + +# def sync_playlists(ytmusic: YTMusic, playlists_dir: str) -> None: +# for filename in os.scandir(playlists_dir): +# if not filename.is_file() or not filename.name.endswith('.csv'): +# print( +# f"{bcolors.FAIL}Warning: Skipping {filename.name}...{bcolors.ENDC}") +# continue +# else: +# playlist_name = title_case(filename.name[:-4]) +# print( +# f"{bcolors.HEADER}Reading {filename.name}...{bcolors.ENDC}") + +# df = pd.read_csv(playlists_dir + filename.name).reset_index() +# songs_to_sync = search_matches(df, ytmusic) + +# print( +# f"{bcolors.OKCYAN}{len(songs_to_sync)}/{len(df.index)} result(s) have been found for the playlist {playlist_name}{bcolors.ENDC}") + +# print("Creating playlist " + playlist_name + "...") +# playlistId = ytmusic.create_playlist( +# playlist_name, "Playlist description") + +# print("Syncing playlist " + playlist_name + "...") +# ytmusic.add_playlist_items(playlistId, map( +# lambda song: song["videoId"], songs_to_sync)) + +# print( +# f"{bcolors.OKGREEN}The {playlist_name} playlist has been synced!{bcolors.ENDC}") From c2f14944837d4849301cbe9bc25d10cfb6a5d609 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 22 Mar 2023 15:52:12 +0100 Subject: [PATCH 08/33] Remove header_row.txt --- header_raw.txt | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 header_raw.txt diff --git a/header_raw.txt b/header_raw.txt deleted file mode 100644 index 845b7a4..0000000 --- a/header_raw.txt +++ /dev/null @@ -1,36 +0,0 @@ -accept: */* -accept-encoding: gzip, deflate, br -accept-language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7 -authorization: SAPISIDHASH 1679311387_dfff4144ded224a54edf530d38964eb140b2061f -content-length: 2035 -content-type: application/json -cookie: VISITOR_INFO1_LIVE=ynhkxBlC3GY; DEVICE_INFO=ChxOekU0TnpZM016RXdPVFV6T1RnMk16SXdOZz09EJz9/p0GGJz9/p0G; PREF=tz=Europe.Rome&f5=30000&f6=40000000&f7=100; YSC=dMhXZcuXWOQ; _gcl_au=1.1.1666305805.1679311190; SID=UggYhjeAjFC2ZxpBtG_aCveQ2Jy42NhFFr0OnRDebs-F4kFwbUHI38GSrVnOSZ09GJxD0w.; __Secure-1PSID=UggYhjeAjFC2ZxpBtG_aCveQ2Jy42NhFFr0OnRDebs-F4kFwKcsW5KmMmeg6q742w7WaUA.; __Secure-3PSID=UggYhjeAjFC2ZxpBtG_aCveQ2Jy42NhFFr0OnRDebs-F4kFwmk1S65kjde-f5FDx_ouElg.; HSID=ASXi3Ge0ewWXm-VMr; SSID=A4v1DnshKFwgKhYU0; APISID=mTFGjWb4BbfvjqN-/AWJ_bKGK7XbCF5n6F; SAPISID=TPMRXnKSgDFnsNPj/AHn0QC9CU3LdRoXzv; __Secure-1PAPISID=TPMRXnKSgDFnsNPj/AHn0QC9CU3LdRoXzv; __Secure-3PAPISID=TPMRXnKSgDFnsNPj/AHn0QC9CU3LdRoXzv; LOGIN_INFO=AFmmF2swRQIgUo7NcKNUHK3f1riWkf3S8ZFoSBbo8P74B--l7ah47WMCIQCfshm0V-EzJ3TBjckDi0_Ia32xAuuOYiwirx8AMIRN7Q:QUQ3MjNmeWFtbkxsTEI5T1lGTjVCeWE0NklXcTUxMDUxOWVpY3ZiZ1I0SUwxQ25Hdm1lYmVLMlJ2WThmSEpqV2tWWmNGRDVNTVVGV3dkb0xtT3ZmSFRCMENvQjJOTDZLRnFod2hDNEFZcnJuNEJuSXpYdTV2QTd2cWxZX2ZHSUZhbEJRMjdZTE9LTnJfaTJjVmlpMUJvVkFfVkNzRG9UZzBB; SIDCC=AFvIBn8mheRUPM2os_P9cfKuphOXsgJLxemVM8ax0YnO60cVSz_j2Qdq_a0lA0mfbfBn0Owe0xY; __Secure-1PSIDCC=AFvIBn-d1QadKd9L-1yqAR01R8QSe0S9x7Fq9BSKjfy5pHAh01KwFtRxoGl4nHRXnKHtqRkNQdCH; __Secure-3PSIDCC=AFvIBn-EzpHbVgN8iSfND7FXlA8HspRF8NObOOZD10w2GuJ2hEoUiFwVdgXFI-2Ixf6aXQCX5Ho1 -dnt: 1 -origin: https://music.youtube.com -referer: https://music.youtube.com/ -sec-ch-ua: "Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111" -sec-ch-ua-arch: "x86" -sec-ch-ua-bitness: "64" -sec-ch-ua-full-version: "111.0.5563.65" -sec-ch-ua-full-version-list: "Google Chrome";v="111.0.5563.65", "Not(A:Brand";v="8.0.0.0", "Chromium";v="111.0.5563.65" -sec-ch-ua-mobile: ?0 -sec-ch-ua-model -sec-ch-ua-platform: "Windows" -sec-ch-ua-platform-version: "10.0.0" -sec-ch-ua-wow64: ?0 -sec-fetch-dest: empty -sec-fetch-mode: same-origin -sec-fetch-site: same-origin -user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 -x-client-data: CJW2yQEIorbJAQjBtskBCKmdygEI3dPKAQjC9soBCJahywEIjozNAQiXls0BCKiWzQEI4pfNAQjkl80BCM2YzQEItJrNAQjom80BCMuczQE= -Decoded: -message ClientVariations { - // Active client experiment variation IDs. - repeated int32 variation_id = [3300117, 3300130, 3300161, 3313321, 3320285, 3324738, 3330198, 3360270, 3361559, 3361576, 3361762, 3361764, 3361869, 3362100, 3362280, 3362379]; -} -x-goog-authuser: 0 -x-goog-visitor-id: Cgt5bmhreEJsQzNHWSi6guGgBg%3D%3D -x-origin: https://music.youtube.com -x-youtube-bootstrap-logged-in: true -x-youtube-client-name: 67 -x-youtube-client-version: 1.20230313.00.00 \ No newline at end of file From bd3f27bc2a3bb18474fc4d1cd6021e7f5ed5cb38 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 22 Mar 2023 15:54:02 +0100 Subject: [PATCH 09/33] update gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 18eb161..3d5a786 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,9 @@ ENV/ env.bak/ venv.bak/ +# ignore header_raw.txt +header_raw.txt + # do not ignore env example file !.env.example From 6c5d2b78550808ac72266e409e93783c741b3139 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 22 Mar 2023 15:59:35 +0100 Subject: [PATCH 10/33] rename menu -> main_menu --- src/cli/menu.py | 2 +- src/main.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/menu.py b/src/cli/menu.py index 7c05a75..5c6cd1b 100644 --- a/src/cli/menu.py +++ b/src/cli/menu.py @@ -3,7 +3,7 @@ from cli.operation import Operation -def menu() -> Operation: +def main_menu() -> Operation: questions = [ inquirer.List( "operation", diff --git a/src/main.py b/src/main.py index e8071db..93c834f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,7 @@ from cli.bcolors import bcolors from spotify import get_playlists, setup_Spotipy from yt_music import setup_YTMusic, sync_playlist -from cli.menu import menu +from cli.menu import main_menu from cli.operation import Operation @@ -11,7 +11,7 @@ playlists_dir = 'resources/playlists/' - operation: Operation = menu() + operation: Operation = main_menu() match operation: From 77bb8171e9fa64a251e6fa23ca0fc770dd2e4f65 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 22 Mar 2023 16:40:40 +0100 Subject: [PATCH 11/33] add playlists checkbox you can choose which playlists sync using a checkbox --- src/cli/menu.py | 23 +++++++++++++++++++++++ src/main.py | 7 ++++--- src/spotify.py | 1 + src/yt_music.py | 4 ++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/cli/menu.py b/src/cli/menu.py index 5c6cd1b..e672597 100644 --- a/src/cli/menu.py +++ b/src/cli/menu.py @@ -1,6 +1,8 @@ from pprint import pprint import inquirer from cli.operation import Operation +from cli.bcolors import bcolors +from playlist import Playlist def main_menu() -> Operation: @@ -14,3 +16,24 @@ def main_menu() -> Operation: answers = inquirer.prompt(questions) return Operation(answers["operation"]) + + +def playlists_checkbox(playlists: list[Playlist]) -> list[Playlist]: + + questions = [ + inquirer.Confirm("isAll", + message=f"{bcolors.BOLD}Do you want to sync all spotify playlists?{bcolors.ENDC}", + default=False), + inquirer.Checkbox("playlists", + message=f"{bcolors.BOLD}Which playlists do you want to sync?{bcolors.ENDC}", + choices=[playlist.name for playlist in playlists], + ignore=lambda answer: answer["isAll"], + ), + ] + + answers = inquirer.prompt(questions) + + if answers["isAll"]: + return playlists + else: + return list(playlist for playlist in playlists if playlist.name.casefold() in [playlist.casefold() for playlist in answers["playlists"]]) diff --git a/src/main.py b/src/main.py index 93c834f..47a8fd9 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,7 @@ from cli.bcolors import bcolors from spotify import get_playlists, setup_Spotipy from yt_music import setup_YTMusic, sync_playlist -from cli.menu import main_menu +from cli.menu import main_menu, playlists_checkbox from cli.operation import Operation @@ -20,11 +20,12 @@ f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}{Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY.value}...{bcolors.ENDC}") playlists = get_playlists(spotify) - for playlist in playlists: + playlists_to_sync = playlists_checkbox(playlists) + for playlist in playlists_to_sync: sync_playlist(playlist, ytmusic) print( - f"{bcolors.OKGREEN}{len(playlists)} have been synced to YT Music{bcolors.ENDC}") + f"\n{bcolors.OKGREEN}{bcolors.BOLD}{len(playlists_to_sync)} playlist(s) have been synced to your YouTube Music account!{bcolors.ENDC}") case Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE: print( diff --git a/src/spotify.py b/src/spotify.py index 5a0ec1d..62f129a 100644 --- a/src/spotify.py +++ b/src/spotify.py @@ -39,4 +39,5 @@ def get_playlists(spotify: Spotify) -> list[Playlist]: f"{str(idx + 1).rjust(right_justify)}. {playlist.name} ({len(playlist.songs)} songs)") playlists.append(playlist) + print() return playlists diff --git a/src/yt_music.py b/src/yt_music.py index 2c21c32..5aba08d 100644 --- a/src/yt_music.py +++ b/src/yt_music.py @@ -61,7 +61,7 @@ def sync_playlist(playlist: Playlist, ytmusic: YTMusic): songs_to_sync = search_matches(playlist.songs, ytmusic) print( - f"Creating playlist \"{playlist.name}\" in your YouTube Music account...") + f"\nCreating playlist \"{playlist.name}\" in your YouTube Music account...") playlistId = ytmusic.create_playlist( playlist.name, playlist.description) @@ -71,7 +71,7 @@ def sync_playlist(playlist: Playlist, ytmusic: YTMusic): lambda song: song.id, songs_to_sync)) print( - f"{bcolors.OKBLUE}The \"{playlist.name}\" playlist has been synced to your YouTube Music account!{bcolors.ENDC}") + f"{bcolors.OKBLUE}\"{playlist.name}\" has been synced to your YouTube Music account!{bcolors.ENDC}") # def sync_playlists(ytmusic: YTMusic, playlists_dir: str) -> None: From ac10280d29e07112a05be6cd5f4db0ae1e76db32 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 22 Mar 2023 18:15:27 +0100 Subject: [PATCH 12/33] move sync operations to sync.py --- src/main.py | 12 ++---------- src/sync.py | 15 +++++++++++++++ src/yt_music.py | 2 -- 3 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 src/sync.py diff --git a/src/main.py b/src/main.py index 47a8fd9..256e2a1 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,6 @@ from cli.bcolors import bcolors from spotify import get_playlists, setup_Spotipy +from sync import sync_spotify_to_ytmusic from yt_music import setup_YTMusic, sync_playlist from cli.menu import main_menu, playlists_checkbox from cli.operation import Operation @@ -16,16 +17,7 @@ match operation: case Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY: - print( - f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}{Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY.value}...{bcolors.ENDC}") - playlists = get_playlists(spotify) - - playlists_to_sync = playlists_checkbox(playlists) - for playlist in playlists_to_sync: - sync_playlist(playlist, ytmusic) - - print( - f"\n{bcolors.OKGREEN}{bcolors.BOLD}{len(playlists_to_sync)} playlist(s) have been synced to your YouTube Music account!{bcolors.ENDC}") + sync_spotify_to_ytmusic(spotify, ytmusic) case Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE: print( diff --git a/src/sync.py b/src/sync.py new file mode 100644 index 0000000..b87e400 --- /dev/null +++ b/src/sync.py @@ -0,0 +1,15 @@ +from cli.bcolors import bcolors +from ytmusicapi import YTMusic +from spotipy.client import Spotify +from cli.operation import Operation + + +def sync_spotify_to_ytmusic(spotify: Spotify, ytmusic: YTMusic): + print(f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}{Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY.value}...{bcolors.ENDC}") + playlists = get_playlists(spotify) + + playlists_to_sync = playlists_checkbox(playlists) + for playlist in playlists_to_sync: + sync_playlist(playlist, ytmusic) + + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}{len(playlists_to_sync)} playlist(s) have been synced to your YouTube Music account!{bcolors.ENDC}") diff --git a/src/yt_music.py b/src/yt_music.py index 5aba08d..e31ea4e 100644 --- a/src/yt_music.py +++ b/src/yt_music.py @@ -1,10 +1,8 @@ from ytmusicapi import YTMusic -import pandas as pd import os from playlist import Playlist from song import Song from cli.bcolors import bcolors -from utils.string_utils import title_case from dotenv import load_dotenv From 46e3b37f035944cc8f01b3afc8161cbc6367363c Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 22 Mar 2023 18:19:04 +0100 Subject: [PATCH 13/33] clean code and remove method related to sync from csv --- resources/playlists/take_it_easy_rock.csv | 43 ------------------- src/cli/menu.py | 1 - src/main.py | 6 +-- src/yt_music.py | 50 +---------------------- 4 files changed, 5 insertions(+), 95 deletions(-) delete mode 100644 resources/playlists/take_it_easy_rock.csv diff --git a/resources/playlists/take_it_easy_rock.csv b/resources/playlists/take_it_easy_rock.csv deleted file mode 100644 index 6a252e0..0000000 --- a/resources/playlists/take_it_easy_rock.csv +++ /dev/null @@ -1,43 +0,0 @@ -"Track URI","Track Name","Artist URI(s)","Artist Name(s)","Album URI","Album Name","Album Artist URI(s)","Album Artist Name(s)","Album Release Date","Album Image URL","Disc Number","Track Number","Track Duration (ms)","Track Preview URL","Explicit","Popularity","ISRC","Added By","Added At" -"spotify:track:37Tmv4NnfQeb0ZgUC4fOJj","Sultans Of Swing","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:2rCS6Xwx32V27pvgFzLzlT","Dire Straits","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1978-10-07","https://i.scdn.co/image/ab67616d0000b2739dfee5404d5e0763998c958e","1","6","348400",,"false","79","GBF089601041","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:5Hk4Mpex0s2ndUpDQ5v2rU","Walk Of Life - Remastered 1996","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:6Pz06FAaeym0JSqVqIkN56","Brothers In Arms (Remastered 1996)","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1985-05-13","https://i.scdn.co/image/ab67616d0000b2733d15b942408bff9f507f189e","1","3","248893",,"false","69","GBUM72102500","spotify:user:zfede97","2022-03-25T10:04:36Z" -"spotify:track:0PNt5WTw092eED0ru9SuIp","Romeo And Juliet","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:3wvclpO3LJmpSQGQ9gBa2a","Making Movies","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1980-10-17","https://i.scdn.co/image/ab67616d0000b273d5b6cadaeec32b45bf0d7cf7","1","2","360666",,"false","70","GBF088000675","spotify:user:zfede97","2022-03-25T10:04:36Z" -"spotify:track:4nFNJmjfgBF7jwv2oBC45b","Money For Nothing - Remastered 1996","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:6Pz06FAaeym0JSqVqIkN56","Brothers In Arms (Remastered 1996)","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1985-05-13","https://i.scdn.co/image/ab67616d0000b2733d15b942408bff9f507f189e","1","2","505333",,"false","65","GBUM72102499","spotify:user:zfede97","2022-03-25T10:04:36Z" -"spotify:track:3elV4E1GOKfF0MVRgdp6eU","Brothers In Arms - Remastered 1996","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:6Pz06FAaeym0JSqVqIkN56","Brothers In Arms (Remastered 1996)","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1985-05-13","https://i.scdn.co/image/ab67616d0000b2733d15b942408bff9f507f189e","1","9","420240",,"false","61","GBUM72102506","spotify:user:zfede97","2022-03-25T10:04:36Z" -"spotify:track:4ZSDreApKOo6eQYFx9qXfD","Tunnel Of Love","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:3wvclpO3LJmpSQGQ9gBa2a","Making Movies","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1980-10-17","https://i.scdn.co/image/ab67616d0000b273d5b6cadaeec32b45bf0d7cf7","1","1","489506",,"false","60","GBF088000674","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:79Scr6iQiMOJ8k2BJHGT2A","This Is Us","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","spotify:album:6oGCz3d9MqAB6OVMUOLibu","All The Roadrunning","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","2006-04-24","https://i.scdn.co/image/ab67616d0000b273638164cc8778088460142f81","1","3","275813",,"false","49","GBUM70600464","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:65f8Ca4HbZCMLhpZPTHW4O","What It Is","spotify:artist:0FI0kxP0BWurTz8cB8BBug","Mark Knopfler","spotify:album:7oIBSEe5L89s2UXK0I8tYg","Sailing To Philadelphia","spotify:artist:0FI0kxP0BWurTz8cB8BBug","Mark Knopfler","2000-09-26","https://i.scdn.co/image/ab67616d0000b273659eb0074558c8b6827df2ec","1","1","295893",,"false","59","GBF080000154","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:5ti5SeraA9Ci7OtbLhEkz2","Sailing To Philadelphia","spotify:artist:0FI0kxP0BWurTz8cB8BBug","Mark Knopfler","spotify:album:5RUE91DSu0BdsiEn4vOrNY","The Best Of Dire Straits & Mark Knopfler - Private Investigations","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Mark Knopfler, Dire Straits","2005-01-01","https://i.scdn.co/image/ab67616d0000b273178419da701ab7c7f693c9ac","2","7","353120",,"false","54","GBF080000155","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:1ih2064mxsKnMDfWN4jXnR","Silvertown Blues","spotify:artist:0FI0kxP0BWurTz8cB8BBug","Mark Knopfler","spotify:album:6IYYXcvEnP4I9eCkXYd53E","Sailing To Philadelphia","spotify:artist:0FI0kxP0BWurTz8cB8BBug","Mark Knopfler","2000-09-26","https://i.scdn.co/image/ab67616d0000b2732f3bd17b4edfcf5580906575","1","7","330906",,"false","31","GBF080000159","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:4NbfqSzyJnOkJWOykgLmeP","Rollin' On","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","spotify:album:6oGCz3d9MqAB6OVMUOLibu","All The Roadrunning","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","2006-04-24","https://i.scdn.co/image/ab67616d0000b273638164cc8778088460142f81","1","5","252360",,"false","34","GBUM70600507","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:6aHrmABARkuroPv9CNDmhS","Beachcombing","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","spotify:album:6oGCz3d9MqAB6OVMUOLibu","All The Roadrunning","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","2006-04-24","https://i.scdn.co/image/ab67616d0000b273638164cc8778088460142f81","1","1","254360",,"false","42","GBUM70600498","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:6zvJJLQdJuM81BDkBJDAh9","All The Roadrunning","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","spotify:album:6oGCz3d9MqAB6OVMUOLibu","All The Roadrunning","spotify:artist:0FI0kxP0BWurTz8cB8BBug, spotify:artist:5s6TJEuHTr9GR894wc6VfP","Mark Knopfler, Emmylou Harris","2006-04-24","https://i.scdn.co/image/ab67616d0000b273638164cc8778088460142f81","1","11","289226",,"false","43","GBF080500645","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:40riOy7x9W7GXjyGp4pjAv","Hotel California - 2013 Remaster","spotify:artist:0ECwFtbIWEVNwjlrfc6xoL","Eagles","spotify:album:2widuo17g5CEC66IbzveRu","Hotel California (2013 Remaster)","spotify:artist:0ECwFtbIWEVNwjlrfc6xoL","Eagles","1976-12-08","https://i.scdn.co/image/ab67616d0000b2734637341b9f507521afa9a778","1","1","391376","https://p.scdn.co/mp3-preview/6fedc11d0f55bef176cc1c5725ac1c57f9a2534a?cid=9950ac751e34487dbbe027c4fd7f8e99","false","82","USEE11300353","spotify:user:zfede97","2022-03-25T09:59:43Z" -"spotify:track:4yugZvBYaoREkJKtbG08Qr","Take It Easy - 2013 Remaster","spotify:artist:0ECwFtbIWEVNwjlrfc6xoL","Eagles","spotify:album:51B7LbLWgYLKBVSpkan8Z7","Eagles (2013 Remaster)","spotify:artist:0ECwFtbIWEVNwjlrfc6xoL","Eagles","1972","https://i.scdn.co/image/ab67616d0000b273c13acd642ba9f6f5f127aa1b","1","1","211577","https://p.scdn.co/mp3-preview/4b32d39b05829f2c442aa869354f0f63cefcef24?cid=9950ac751e34487dbbe027c4fd7f8e99","false","76","USEE11300314","spotify:user:zfede97","2022-03-25T10:00:22Z" -"spotify:track:46MX86XQqYCZRvwPpeq4Gi","Lake Shore Drive","spotify:artist:4VmWYQQ5M9N9AiAx14v2yg","Aliotta Haynes Jeremiah","spotify:album:24NY6n4z0tDzpt8QCiWEGV","Lake Shore Drive","spotify:artist:4VmWYQQ5M9N9AiAx14v2yg","Aliotta Haynes Jeremiah","1972","https://i.scdn.co/image/ab67616d0000b273bfd869b5a775481785ce55ad","1","1","235173","https://p.scdn.co/mp3-preview/6613f85913f2794b417f31af93b8271a49e11709?cid=9950ac751e34487dbbe027c4fd7f8e99","false","62","uscgh0554198","spotify:user:zfede97","2022-03-25T10:03:42Z" -"spotify:track:57iDDD9N9tTWe75x6qhStw","Bitter Sweet Symphony","spotify:artist:2cGwlqi3k18jFpUyTrsR84","The Verve","spotify:album:52AeC4gwbxDfFlLHgK1ByD","Urban Hymns (Remastered 2016)","spotify:artist:2cGwlqi3k18jFpUyTrsR84","The Verve","1997-09-29","https://i.scdn.co/image/ab67616d0000b273707d13d3f87652e737e94d45","1","1","357266",,"false","78","GBUM71601816","spotify:user:zfede97","2022-03-25T10:06:49Z" -"spotify:track:7iN1s7xHE4ifF5povM6A48","Let It Be - Remastered 2009","spotify:artist:3WrFJ7ztbogyGnTHbHJFl2","The Beatles","spotify:album:0jTGHV5xqHPvEcwL8f6YU5","Let It Be (Remastered)","spotify:artist:3WrFJ7ztbogyGnTHbHJFl2","The Beatles","1970-05-08","https://i.scdn.co/image/ab67616d0000b27384243a01af3c77b56fe01ab1","1","6","243026",,"false","76","GBAYE0601713","spotify:user:zfede97","2022-03-25T10:08:22Z" -"spotify:track:0jWgAnTrNZmOGmqgvHhZEm","What's Up?","spotify:artist:0Je74SitssvJg1w4Ra2EK7","4 Non Blondes","spotify:album:2P8M5eo4zWFD0JJtH4D0iA","Bigger, Better, Faster, More !","spotify:artist:0Je74SitssvJg1w4Ra2EK7","4 Non Blondes","1992-01-01","https://i.scdn.co/image/ab67616d0000b273381371cb8ce680d0dc324600","1","3","295533",,"false","79","USIR19200553","spotify:user:zfede97","2022-03-25T10:09:02Z" -"spotify:track:4RvWPyQ5RL0ao9LPZeSouE","Everybody Wants To Rule The World","spotify:artist:4bthk9UfsYUYdcFyqxmSUU","Tears For Fears","spotify:album:3myPwaMYjdwhtq0nFgeG6W","Songs From The Big Chair (Super Deluxe Edition)","spotify:artist:4bthk9UfsYUYdcFyqxmSUU","Tears For Fears","1985-02-25","https://i.scdn.co/image/ab67616d0000b27322463d6939fec9e17b2a6235","1","3","251488",,"false","85","GBF088590110","spotify:user:zfede97","2022-03-25T10:10:06Z" -"spotify:track:4JiEyzf0Md7KEFFGWDDdCr","Knockin' On Heaven's Door","spotify:artist:3qm84nBOXUEQ2vnTfUTTFC","Guns N' Roses","spotify:album:00eiw4KOJZ7eC3NBEpmH4C","Use Your Illusion II","spotify:artist:3qm84nBOXUEQ2vnTfUTTFC","Guns N' Roses","1991-09-18","https://i.scdn.co/image/ab67616d0000b27392d21aef6c0d288cc4c05973","1","4","336000",,"false","77","USGF19142004","spotify:user:zfede97","2022-03-25T10:14:06Z" -"spotify:track:2LawezPeJhN4AWuSB0GtAU","Have You Ever Seen The Rain","spotify:artist:3IYUhFvPQItj6xySrBmZkd","Creedence Clearwater Revival","spotify:album:372cMadhAGlNuDnc8TssqF","Pendulum (Expanded Edition)","spotify:artist:3IYUhFvPQItj6xySrBmZkd","Creedence Clearwater Revival","1970-12-07","https://i.scdn.co/image/ab67616d0000b27351f311c2fb06ad2789e3ff91","1","4","160133",,"false","83","USC4R0817643","spotify:user:zfede97","2022-03-25T10:16:14Z" -"spotify:track:1P4Frq8ZqkdJlChASoGFPZ","Scarface (Push It To The Limit)","spotify:artist:3Rfb8wuY9YHVntDXJqPW6r","Paul Engemann","spotify:album:0h7e2N3754OF6YyVEFxHzc","Scarface","spotify:artist:0LyfQWJT6nXafLPZqxe9Of","Artisti Vari","1983","https://i.scdn.co/image/ab67616d0000b2732f478ddce1c0c4bf1c46e2b3","1","1","184706",,"false","56","USMC10346186","spotify:user:zfede97","2022-03-26T14:17:46Z" -"spotify:track:6uf2YuVXXrC23JDUHFWmUQ","Citizen Cain","spotify:artist:5Jj4mqGYiplyowPLKkJLHt, spotify:artist:4EGlaDAVZTNNknDdKHrHRp","Tom Cochrane, Red Rider","spotify:album:5NgfmDJdDGJCb7LgrCeJrA","Tom Cochrane And Red Rider","spotify:artist:5Jj4mqGYiplyowPLKkJLHt","Tom Cochrane","1986-01-01","https://i.scdn.co/image/ab67616d0000b27347cca641b9271c0b335cb023","1","5","212106",,"false","18","USCA20904420","spotify:user:zfede97","2022-04-14T14:04:32Z" -"spotify:track:0pQskrTITgmCMyr85tb9qq","Starman - 2012 Remaster","spotify:artist:0oSGxfWSnnOXhD2fKuz2Gy","David Bowie","spotify:album:48D1hRORqJq52qsnUYZX56","The Rise and Fall of Ziggy Stardust and the Spiders from Mars (2012 Remaster)","spotify:artist:0oSGxfWSnnOXhD2fKuz2Gy","David Bowie","1972-06-06","https://i.scdn.co/image/ab67616d0000b273c41f4e1133b0e6c5fcf58680","1","4","254293","https://p.scdn.co/mp3-preview/6b0a9731bccd31cea4f1e229800e82b5c7be8f53?cid=9950ac751e34487dbbe027c4fd7f8e99","false","75","USJT11200004","spotify:user:zfede97","2022-04-14T20:04:15Z" -"spotify:track:0lIoY4ZQsdn5QzhraM9o9u","Because the Night","spotify:artist:0vYkHhJ48Bs3jWcvZXvOrP","Patti Smith","spotify:album:1p6cWoueuunhpgy6131zAd","Easter","spotify:artist:0vYkHhJ48Bs3jWcvZXvOrP","Patti Smith","1978","https://i.scdn.co/image/ab67616d0000b273a28eabe111f67a386e75a31a","1","3","204800","https://p.scdn.co/mp3-preview/9803a684e4d2af90d8fec47f95390ee8ace8f2d9?cid=9950ac751e34487dbbe027c4fd7f8e99","false","68","USAR17800008","spotify:user:zfede97","2022-04-15T09:51:23Z" -"spotify:track:6t1FIJlZWTQfIZhsGjaulM","Video Killed The Radio Star","spotify:artist:057gc1fxmJ2vkctjQJ7Tal","The Buggles","spotify:album:5KKpKvLOS4tCV7cSOwIOWF","The Age Of Plastic","spotify:artist:057gc1fxmJ2vkctjQJ7Tal","The Buggles","1980-01-10","https://i.scdn.co/image/ab67616d0000b27370b6cfff0487d653a8c7ce01","1","2","253800",,"false","62","GBAAN7900013","spotify:user:zfede97","2022-04-18T11:35:02Z" -"spotify:track:2oKOGvdJ5BQEiguaSQJJpc","Your Latest Trick - Remastered 1996","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:6Pz06FAaeym0JSqVqIkN56","Brothers In Arms (Remastered 1996)","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1985-05-13","https://i.scdn.co/image/ab67616d0000b2733d15b942408bff9f507f189e","1","4","389666",,"false","63","GBUM72102501","spotify:user:zfede97","2022-04-19T21:13:54Z" -"spotify:track:0LrwgdLsFaWh9VXIjBRe8t","Changes - 2015 Remaster","spotify:artist:0oSGxfWSnnOXhD2fKuz2Gy","David Bowie","spotify:album:6fQElzBNTiEMGdIeY0hy5l","Hunky Dory (2015 Remaster)","spotify:artist:0oSGxfWSnnOXhD2fKuz2Gy","David Bowie","1971-12-17","https://i.scdn.co/image/ab67616d0000b273e464904cc3fed2b40fc55120","1","1","217746","https://p.scdn.co/mp3-preview/169c24f8dec808a59b1d58d0611d269cc88175fc?cid=9950ac751e34487dbbe027c4fd7f8e99","false","67","USJT11500128","spotify:user:zfede97","2022-04-30T12:22:10Z" -"spotify:track:72Z17vmmeQKAg8bptWvpVG","Space Oddity - 2015 Remaster","spotify:artist:0oSGxfWSnnOXhD2fKuz2Gy","David Bowie","spotify:album:1ay9Z4R5ZYI2TY7WiDhNYQ","David Bowie (aka Space Oddity) [2015 Remaster]","spotify:artist:0oSGxfWSnnOXhD2fKuz2Gy","David Bowie","1969-11-04","https://i.scdn.co/image/ab67616d0000b2733395f3e809dfbc2b1101d464","1","1","318026","https://p.scdn.co/mp3-preview/9240f576fa464f8e3fe38f1c83fd23cd4e516638?cid=9950ac751e34487dbbe027c4fd7f8e99","false","70","USJT11500173","spotify:user:zfede97","2022-04-30T12:22:59Z" -"spotify:track:5gOnivVq0hLxPvIPC00ZhF","Cosmic Dancer","spotify:artist:3dBVyJ7JuOMt4GE9607Qin","T. Rex","spotify:album:2wnq5e000z2hT7qS2F8jZ5","Electric Warrior","spotify:artist:3dBVyJ7JuOMt4GE9607Qin","T. Rex","1971-09-24","https://i.scdn.co/image/ab67616d0000b27361a23657edb5d80fa18a630d","1","2","266533",,"false","55","GBCHM7100022","spotify:user:zfede97","2022-05-25T21:21:07Z" -"spotify:track:2QfiRTz5Yc8DdShCxG1tB2","Johnny B. Goode","spotify:artist:293zczrfYafIItmnmM3coR","Chuck Berry","spotify:album:6eedtCtCjibu80yOhylSGL","Berry Is On Top","spotify:artist:293zczrfYafIItmnmM3coR","Chuck Berry","1959-07-01","https://i.scdn.co/image/ab67616d0000b273a496dc8c33ca6d10668b3157","1","6","161560",,"false","74","USMC15719963","spotify:user:zfede97","2022-06-11T09:42:46Z" -"spotify:track:5yrsBzgHkfu2idkl2ILQis","You Don't Mess Around with Jim","spotify:artist:1R6Hx1tJ2VOUyodEpC12xM","Jim Croce","spotify:album:3L9sVl5T7UpFK6tNeEiXdy","You Don't Mess Around With Jim","spotify:artist:1R6Hx1tJ2VOUyodEpC12xM","Jim Croce","1972-04-01","https://i.scdn.co/image/ab67616d0000b27398078d325c25a5ac4081cfba","1","1","184640","https://p.scdn.co/mp3-preview/8bfb62484e1fbd99d648c80cd64837e0a7748eff?cid=9950ac751e34487dbbe027c4fd7f8e99","false","60","QMRSZ1601347","spotify:user:zfede97","2022-06-11T08:53:27Z" -"spotify:track:1fDsrQ23eTAVFElUMaf38X","American Pie","spotify:artist:1gRNBaI4yn6wCCTvRhGWh8","Don McLean","spotify:album:10jsW2NYd9blCrDITMh2zS","American Pie","spotify:artist:1gRNBaI4yn6wCCTvRhGWh8","Don McLean","1971","https://i.scdn.co/image/ab67616d0000b2730085dd4362653ef4c54ebbeb","1","1","516893",,"false","73","USEM38600088","spotify:user:zfede97","2022-06-13T11:14:18Z" -"spotify:track:1H5VQuShs4qfwBXyHF0PeH","Narcotic - Radio Edit","spotify:artist:0wgLwvDNCHdJ9FblyyD4Dc","Liquido","spotify:album:79EDJF2fJnnM7YVKCAeQXj","Narcotic","spotify:artist:0wgLwvDNCHdJ9FblyyD4Dc","Liquido","1998-07-10","https://i.scdn.co/image/ab67616d0000b2738b28efbba32db47b0172fd9e","1","1","236150","https://p.scdn.co/mp3-preview/3eeccda36159f511f9b4ec008b2a30622bc5bfc8?cid=9950ac751e34487dbbe027c4fd7f8e99","false","68","DEG129800581","spotify:user:zfede97","2022-12-17T23:36:05Z" -"spotify:track:1JO1xLtVc8mWhIoE3YaCL0","Happy Together","spotify:artist:2VIoWte1HPDbZ2WqHd2La7","The Turtles","spotify:album:2pMxs38Y5A0mmHrcu3twvB","Happy Together","spotify:artist:2VIoWte1HPDbZ2WqHd2La7","The Turtles","1967","https://i.scdn.co/image/ab67616d0000b27372649ad8e79d1e8bdd54c929","1","6","176293","https://p.scdn.co/mp3-preview/8c691e6232f9abae21be72ed15407fd90657a73e?cid=9950ac751e34487dbbe027c4fd7f8e99","false","73","USA560587940","spotify:user:zfede97","2022-12-24T22:15:46Z" -"spotify:track:1HuAR7RyNWQq6vHwOFHWqx","I'm on My Way","spotify:artist:1A92IAcd7A6npCA33oGM5i","The Proclaimers","spotify:album:5sK78apv4yOoXjxRL4kOdJ","Sunshine on Leith","spotify:artist:1A92IAcd7A6npCA33oGM5i","The Proclaimers","1988","https://i.scdn.co/image/ab67616d0000b273cebdf1f7660ace8c2a80585c","1","8","225586","https://p.scdn.co/mp3-preview/653f6604f43d9d9f9456c28c5ec27cd08d715be3?cid=9950ac751e34487dbbe027c4fd7f8e99","false","59","GBAYK8800056","spotify:user:zfede97","2023-01-12T07:58:33Z" -"spotify:track:6XUHsYE38CEbYunT983O9G","Give A Little Bit","spotify:artist:3JsMj0DEzyWc0VDlHuy9Bx","Supertramp","spotify:album:4X87hQ57jTYQTcYTaJWK5w","Even In The Quietest Moments","spotify:artist:3JsMj0DEzyWc0VDlHuy9Bx","Supertramp","1977-01-01","https://i.scdn.co/image/ab67616d0000b273bddcc30c6a3288e725aec2df","1","1","248173",,"false","73","USAM19500634","spotify:user:zfede97","2023-01-12T08:07:23Z" -"spotify:track:2kkvB3RNRzwjFdGhaUA0tz","Layla","spotify:artist:2rc78XDH9zuJP6bm78lU8Z","Derek & The Dominos","spotify:album:5iIWnMgvSM8uEBwXKsPcXM","Layla And Other Assorted Love Songs (Remastered 2010)","spotify:artist:2rc78XDH9zuJP6bm78lU8Z","Derek & The Dominos","1970-11-01","https://i.scdn.co/image/ab67616d0000b273fbcaf7402f38faac27610efc","1","13","423840",,"false","72","GBUM71028890","spotify:user:zfede97","2023-01-12T08:08:36Z" -"spotify:track:31AOj9sFz2gM0O3hMARRBx","Losing My Religion","spotify:artist:4KWTAlx2RvbpseOGMEmROg","R.E.M.","spotify:album:6yEuIwTQpciH1qtj7mP5GK","Out Of Time (25th Anniversary Edition)","spotify:artist:4KWTAlx2RvbpseOGMEmROg","R.E.M.","1991-03-12","https://i.scdn.co/image/ab67616d0000b273e2dd4e821bcc3f70dc0c8ffd","1","2","268426",,"false","82","USC4R1605373","spotify:user:zfede97","2023-02-05T19:32:29Z" -"spotify:track:4DDyybdhC9su038YIG6JDj","So Far Away - Remastered 1996","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","spotify:album:6Pz06FAaeym0JSqVqIkN56","Brothers In Arms (Remastered 1996)","spotify:artist:0WwSkZ7LtFUFjGjMZBMt6T","Dire Straits","1985-05-13","https://i.scdn.co/image/ab67616d0000b2733d15b942408bff9f507f189e","1","1","306373",,"false","58","GBUM72102498","spotify:user:zfede97","2023-02-16T22:17:20Z" -"spotify:track:0iOZM63lendWRTTeKhZBSC","Mrs. Robinson - From ""The Graduate"" Soundtrack","spotify:artist:70cRZdQywnSFp9pnc2WTCE","Simon & Garfunkel","spotify:album:3bzgbgiytguTDnwzflAZr2","Bookends","spotify:artist:70cRZdQywnSFp9pnc2WTCE","Simon & Garfunkel","1968-04-03","https://i.scdn.co/image/ab67616d0000b273d8fb5b4308dc27f210064ef4","1","10","244026","https://p.scdn.co/mp3-preview/b48d5e6bd6312c23e674b6d3c5293438190cd99b?cid=9950ac751e34487dbbe027c4fd7f8e99","false","73","USSM16800379","spotify:user:zfede97","2023-02-17T20:25:24Z" \ No newline at end of file diff --git a/src/cli/menu.py b/src/cli/menu.py index e672597..d77365e 100644 --- a/src/cli/menu.py +++ b/src/cli/menu.py @@ -1,4 +1,3 @@ -from pprint import pprint import inquirer from cli.operation import Operation from cli.bcolors import bcolors diff --git a/src/main.py b/src/main.py index 256e2a1..4b33718 100644 --- a/src/main.py +++ b/src/main.py @@ -1,8 +1,8 @@ from cli.bcolors import bcolors -from spotify import get_playlists, setup_Spotipy +from spotify import setup_Spotipy from sync import sync_spotify_to_ytmusic -from yt_music import setup_YTMusic, sync_playlist -from cli.menu import main_menu, playlists_checkbox +from yt_music import setup_YTMusic +from cli.menu import main_menu from cli.operation import Operation diff --git a/src/yt_music.py b/src/yt_music.py index e31ea4e..0b8bd83 100644 --- a/src/yt_music.py +++ b/src/yt_music.py @@ -22,22 +22,6 @@ def setup_YTMusic() -> YTMusic: return ytmusic -# def search_matches(df: pd.DataFrame, ytmusic: YTMusic) -> list: -# songs_to_sync = [] -# for index, row in df.iterrows(): -# song = Song.get_song_from_csv_row(row) - -# print("Looking for matches for " + str(song)) -# search_result = song.get_search_result(ytmusic) -# if (search_result is None): -# print( -# f"{bcolors.WARNING}WARNING: No match found for track nr. {str(index + 1)}: {str(song)}{bcolors.ENDC}") -# else: -# songs_to_sync.append(search_result) - -# return songs_to_sync - - def search_matches(songs: list[Song], ytmusic: YTMusic) -> list[Song]: songs_to_sync = [] for idx, song in enumerate(songs): @@ -54,7 +38,7 @@ def search_matches(songs: list[Song], ytmusic: YTMusic) -> list[Song]: def sync_playlist(playlist: Playlist, ytmusic: YTMusic): - print(f"{bcolors.BOLD}\nSearching matches for songs in playlist \"{playlist.name}\"{bcolors.ENDC}") + print(f"{bcolors.BOLD}\nSearching matches for songs in \"{playlist.name}\" playlist{bcolors.ENDC}") # for each song in the Spotify playlist, search for a match on YouTube Music songs_to_sync = search_matches(playlist.songs, ytmusic) @@ -68,34 +52,4 @@ def sync_playlist(playlist: Playlist, ytmusic: YTMusic): ytmusic.add_playlist_items(playlistId, map( lambda song: song.id, songs_to_sync)) - print( - f"{bcolors.OKBLUE}\"{playlist.name}\" has been synced to your YouTube Music account!{bcolors.ENDC}") - - -# def sync_playlists(ytmusic: YTMusic, playlists_dir: str) -> None: -# for filename in os.scandir(playlists_dir): -# if not filename.is_file() or not filename.name.endswith('.csv'): -# print( -# f"{bcolors.FAIL}Warning: Skipping {filename.name}...{bcolors.ENDC}") -# continue -# else: -# playlist_name = title_case(filename.name[:-4]) -# print( -# f"{bcolors.HEADER}Reading {filename.name}...{bcolors.ENDC}") - -# df = pd.read_csv(playlists_dir + filename.name).reset_index() -# songs_to_sync = search_matches(df, ytmusic) - -# print( -# f"{bcolors.OKCYAN}{len(songs_to_sync)}/{len(df.index)} result(s) have been found for the playlist {playlist_name}{bcolors.ENDC}") - -# print("Creating playlist " + playlist_name + "...") -# playlistId = ytmusic.create_playlist( -# playlist_name, "Playlist description") - -# print("Syncing playlist " + playlist_name + "...") -# ytmusic.add_playlist_items(playlistId, map( -# lambda song: song["videoId"], songs_to_sync)) - -# print( -# f"{bcolors.OKGREEN}The {playlist_name} playlist has been synced!{bcolors.ENDC}") + print(f"{bcolors.OKBLUE}\"{playlist.name}\" has been synced to your YouTube Music account!{bcolors.ENDC}") From 42524f4f200ef4b5ab2178e91ebb3d68a2823045 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Fri, 24 Mar 2023 18:28:56 +0100 Subject: [PATCH 14/33] fix imports --- src/sync.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sync.py b/src/sync.py index b87e400..4cd1fd9 100644 --- a/src/sync.py +++ b/src/sync.py @@ -1,7 +1,10 @@ from cli.bcolors import bcolors from ytmusicapi import YTMusic from spotipy.client import Spotify +from cli.menu import playlists_checkbox from cli.operation import Operation +from spotify import get_playlists +from yt_music import sync_playlist def sync_spotify_to_ytmusic(spotify: Spotify, ytmusic: YTMusic): From e9e9cb697dc709d9bf86edb40f7aa67f63b7704b Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Fri, 24 Mar 2023 18:45:34 +0100 Subject: [PATCH 15/33] add menu to manage case no playlist selected --- src/cli/menu.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/cli/menu.py b/src/cli/menu.py index d77365e..73e867b 100644 --- a/src/cli/menu.py +++ b/src/cli/menu.py @@ -34,5 +34,19 @@ def playlists_checkbox(playlists: list[Playlist]) -> list[Playlist]: if answers["isAll"]: return playlists + + elif answers["playlists"] == []: + questions = [ + inquirer.Confirm("isEmpty", + message=f"{bcolors.BOLD}You have not selected any playlists. Do you want to exit?{bcolors.ENDC}", + default=False), + ] + + if (inquirer.prompt(questions)["isEmpty"]): + exit(0) + else: + print() + return playlists_checkbox(playlists) + else: return list(playlist for playlist in playlists if playlist.name.casefold() in [playlist.casefold() for playlist in answers["playlists"]]) From d0d31dbd212cbc59e744cd74c6a3bc0622d91cee Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 09:56:03 +0200 Subject: [PATCH 16/33] update gitignore (ignoring .idea/) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3d5a786..77292d5 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +.idea/ \ No newline at end of file From 1b2ba26b72559d136cf16c844312bc997ec95981 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 10:28:08 +0200 Subject: [PATCH 17/33] document playlist.py --- src/main.py | 10 +++--- src/playlist.py | 84 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/main.py b/src/main.py index 4b33718..5810206 100644 --- a/src/main.py +++ b/src/main.py @@ -1,16 +1,16 @@ -from cli.bcolors import bcolors +"""Main file for the application.""" from spotify import setup_Spotipy from sync import sync_spotify_to_ytmusic from yt_music import setup_YTMusic +from cli.bcolors import bcolors from cli.menu import main_menu from cli.operation import Operation - if __name__ == "__main__": spotify = setup_Spotipy() ytmusic = setup_YTMusic() - playlists_dir = 'resources/playlists/' + PLAYLIST_DIR = 'resources/playlists/' operation: Operation = main_menu() @@ -20,8 +20,8 @@ sync_spotify_to_ytmusic(spotify, ytmusic) case Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE: - print( - f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}{Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE.value}...{bcolors.ENDC}") + print(f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}" + f"{Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE.value}...{bcolors.ENDC}") print(f"{bcolors.WARNING}Not implemented yet...{bcolors.ENDC}") case _: diff --git a/src/playlist.py b/src/playlist.py index 5e7047e..e756d38 100644 --- a/src/playlist.py +++ b/src/playlist.py @@ -1,11 +1,28 @@ +"""This module contains the Playlist class and its methods.""" + from __future__ import annotations from dataclasses import dataclass -from song import Song, get_song_from_spotify_json from spotipy.client import Spotify +from song import Song, get_song_from_spotify_json from cli.bcolors import bcolors def get_playlist_from_spotify(json, spotify: Spotify) -> Playlist: + """ + Creates a Playlist object from a Spotify playlist JSON object. + + Parameters + ---------- + json : dict + The JSON object of the playlist. + spotify : Spotify + The Spotify object to use for the API calls. + + Returns + ------- + Playlist + The Playlist object. + """ name = json["name"] id = json["id"] image = json["images"][0]["url"] if len(json["images"]) > 0 else None @@ -17,10 +34,27 @@ def get_playlist_from_spotify(json, spotify: Spotify) -> Playlist: def get_songs_by_playlist_id(playlist_id: str, total_songs: int, spotify: Spotify) -> list[Song]: + """ + Gets all songs from a Spotify playlist by its ID. + + Parameters + ---------- + playlist_id : str + The ID of the playlist. + total_songs : int + The total number of songs in the playlist. + spotify : Spotify + The Spotify object to use for the API calls. + + Returns + ------- + list[Song] + A list of Song objects. + """ if total_songs == 0: return [] - elif total_songs <= 100: + if total_songs <= 100: songs_json = spotify.playlist_items( playlist_id, limit=total_songs)['items'] @@ -38,16 +72,49 @@ def get_songs_by_playlist_id(playlist_id: str, total_songs: int, spotify: Spotif for song in songs_json: songs.append(get_song_from_spotify_json(song["track"])) - if (total_songs != len(songs)): - print(f"{bcolors.WARNING}WARNING: Playlist ID {playlist_id}: {total_songs} songs were expected, but only {len(songs)} were found{bcolors.ENDC}") + if total_songs != len(songs): + print(f"{bcolors.WARNING}WARNING: Playlist ID {playlist_id}: " + f"{total_songs} songs were expected, but only {len(songs)} were found{bcolors.ENDC}") return songs -@ dataclass +@dataclass class Playlist: + """ + A class used to represent a playlist. + + Attributes + ---------- + name : str + The name of the playlist. + id : str + The ID of the playlist. + image : str + The URL of the playlist's image. + description : str + The description of the playlist. + songs : list[Song] + A list of Song objects. + """ - def __init__(self, name: str, id: str, image: str, description: str, songs: list[Song] = []): + def __init__(self, name: str, id: str, image: str, description: str, songs=None): + """ + Parameters + ---------- + name : str + The name of the playlist. + id : str + The ID of the playlist. + image : str + The URL of the playlist's image. + description : str + The description of the playlist. + songs : list[Song], optional + A list of Song objects. If None, an empty list is used. + """ + if songs is None: + songs = [] self.name: str = name self.id: str = id self.image: str = image @@ -55,4 +122,7 @@ def __init__(self, name: str, id: str, image: str, description: str, songs: list self.songs: list[Song] = songs def __str__(self): - return "Playlist{name=" + self.name + ", id=" + self.id + ", image=" + self.image + ", description=" + self.description + "}" + return "Playlist{name=" + self.name + \ + ", id=" + self.id + \ + ", image=" + self.image + \ + ", description=" + self.description + "}" From 7994f92c4618ca52941045ecf9f2fcd9166b32b0 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 10:53:49 +0200 Subject: [PATCH 18/33] document song.py --- src/song.py | 254 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 223 insertions(+), 31 deletions(-) diff --git a/src/song.py b/src/song.py index 5cab6bc..09af702 100644 --- a/src/song.py +++ b/src/song.py @@ -1,14 +1,45 @@ +"""This module contains the Song class and its methods.""" + from __future__ import annotations + from dataclasses import dataclass -from utils.string_utils import string_similarity, remove_parenthesis_content, get_string_before_dash + from ytmusicapi import YTMusic +from utils.string_utils import get_string_before_dash, remove_parenthesis_content, string_similarity + def join_artists_names(artists: list[str]) -> str: + """ + Joins a list of artists names into a single string. + + Parameters + ---------- + artists : list[str] + The list of artists names. + + Returns + ------- + str + The joined string. + """ return ", ".join(artists) def get_song_from_ytmusic_json(args) -> Song: + """ + Creates a Song object from a YTMusic song JSON object. + + Parameters + ---------- + args : dict + The JSON object of the song. + + Returns + ------- + Song + The Song object. + """ title = args["title"] artist = list(map(lambda a: a["name"], args["artists"])) album = args["album"]["name"] if args["album"] is not None else None @@ -21,11 +52,23 @@ def get_song_from_ytmusic_json(args) -> Song: def get_song_from_spotify_json(args) -> Song: + """ + Creates a Song object from a Spotify song JSON object. + + Parameters + ---------- + args : dict + The JSON object of the song. + + Returns + ------- + Song + The Song object. + """ title = args["name"] artist = list(map(lambda a: a["name"], args["artists"])) album = args["album"]["name"] - duration = args["duration_ms"] / \ - 1000 if args["duration_ms"] is not None else None + duration = args["duration_ms"] / 1000 if args["duration_ms"] is not None else None is_explicit = args["explicit"] match args["album"]["release_date_precision"]: @@ -43,8 +86,51 @@ def get_song_from_spotify_json(args) -> Song: @dataclass class Song: + """ + This class represents a song. + + Attributes + ---------- + id : str + The ID of the song. + title : str + The title of the song. + artists : list[str] + The list of artists names. + album : str + The album name. + duration : int + The duration of the song in seconds. + is_explicit : bool + Whether the song is explicit or not. + year : int + The year of the song. + id : str + The ID of the song. + """ - def __init__(self, title: str, artists: list[str], album: str, duration: int = None, is_explicit: bool = False, year: int = None, id: str = None): + def __init__(self, title: str, artists: list[str], album: str, duration: int = None, is_explicit: bool = False, + year: int = None, id: str = None): + """ + The constructor for the Song class. + + Parameters + ---------- + title : str + The title of the song. + artists : list[str] + The list of artists names. + album : str + The album name. + duration : int, optional + The duration of the song in seconds, by default None + is_explicit : bool, optional + Whether the song is explicit or not, by default False + year : int, optional + The year of the song, by default None + id : str, optional + The ID of the song on Spotify or YouTube Music service, by default None + """ self.id: str = id self.title: str = title self.artists: list[str] = artists @@ -53,37 +139,67 @@ def __init__(self, title: str, artists: list[str], album: str, duration: int = N self.is_explicit: bool = is_explicit self.year: int = year - @classmethod - def get_song_from_csv_row(cls, args) -> Song: - title = args["Track Name"] - artist = args["Artist Name(s)"] - album = args["Album Name"] - duration = args["Track Duration (ms)"] - is_explicit = args["Explicit"] - year = args["Album Release Date"].split( - "-")[0] if args["Album Release Date"] is not None else None + def get_search_query(self) -> str: + """ + Returns the search query for the song. - song = cls(title, artist, album, - duration, is_explicit, year) - return song + Returns + ------- + str + The search query. - def get_search_query(self) -> str: + Examples + -------- + >>> song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + >>> song.get_search_query() + 'Guns N' Roses - Knockin' On Heaven's Door (Use Your Illusion II)' + """ return join_artists_names(self.artists) + " - " + self.title + " (" + self.album + ")" def get_search_result(self, ytmusic: YTMusic) -> Song: + """ + Returns the best search result on YouTube Music for the song. + + Parameters + ---------- + ytmusic : YTMusic + The YTMusic object. + + Returns + ------- + Song + The best search result on YouTube Music. + """ search_results = ytmusic.search( query=self.get_search_query(), filter="songs") best_result = next( - (get_song_from_ytmusic_json(res) for res in search_results if self.is_similar(get_song_from_ytmusic_json(res), True)), None) + (get_song_from_ytmusic_json(res) for res in search_results if + self.is_similar(get_song_from_ytmusic_json(res), True)), None) - if (best_result is None): + if best_result is None: best_result = next( - (get_song_from_ytmusic_json(res) for res in search_results if self.is_similar(get_song_from_ytmusic_json(res), False)), None) + (get_song_from_ytmusic_json(res) for res in search_results if + self.is_similar(get_song_from_ytmusic_json(res), False)), None) return best_result def is_similar(self, other: Song, is_detailed_search: bool = True) -> bool: + """ + Returns whether the song is similar to another song. + + Parameters + ---------- + other : Song + The other song. + is_detailed_search : bool, optional + Whether the search is detailed or not, by default True + + Returns + ------- + bool + Whether the song is similar to another song. + """ return (self.is_similar_title(other.title, is_detailed_search) and self.is_similar_artists(other.artists, is_detailed_search) and self.is_similar_album(other.album, is_detailed_search) and @@ -93,6 +209,21 @@ def is_similar(self, other: Song, is_detailed_search: bool = True) -> bool: (self.year == other.year if self.year is not None and other.year is not None else True)) def is_similar_title(self, title: str, is_detailed_search: bool = True) -> bool: + """ + Returns whether the song title is similar to another song title. + + Parameters + ---------- + title : str + The other song title. + is_detailed_search : bool, optional + Whether the search is detailed or not, by default True + + Returns + ------- + bool + Whether the song title is similar to another song title. + """ self_title = self.title if is_detailed_search else remove_parenthesis_content( get_string_before_dash(self.title)) other_title = title if is_detailed_search else remove_parenthesis_content( @@ -101,23 +232,84 @@ def is_similar_title(self, title: str, is_detailed_search: bool = True) -> bool: return string_similarity(self_title, other_title) > 0.8 def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True) -> bool: - if (is_detailed_search): - if len(self.artists) != len(artists): - return False - else: - return all(map(lambda a: string_similarity(a[0], a[1]) > 0.8, zip(self.artists, artists))) - else: - return self.artists[0] == artists[0] + """ + Returns whether the song artists are similar to another song artists. + + Parameters + ---------- + artists : list[str] + The other song artists. + is_detailed_search : bool, optional + Whether the search is detailed or not, by default True. + If True, the artists names must be in the same order. + If False, the first artist name must be the same. + + Returns + ------- + bool + Whether the song artists are similar to another song artists. + + Examples + -------- + >>> song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + >>> song.is_similar_artists(["Guns N' Roses"], True) + True + >>> song.is_similar_artists(["Guns N' Roses"], False) + True + >>> song.is_similar_artists(["Guns N' Roses", "Axl Rose"], True) + False + >>> song.is_similar_artists(["Guns N' Roses", "Axl Rose"], False) + True + """ + if is_detailed_search: + return all(map(lambda a: string_similarity(a[0], a[1]) > 0.8, zip(self.artists, artists))) if len( + self.artists) == len(artists) else False + + return self.artists[0] == artists[0] def is_similar_album(self, album: str, is_detailed_search: bool = True) -> bool: - return string_similarity(remove_parenthesis_content(self.album), remove_parenthesis_content(album)) > 0.5 if is_detailed_search else True + """ + Returns whether the song album is similar to another song album. - def is_live(self, title: str = None) -> bool: - return "live" in title.lower() if title is not None else "live" in self.title.lower() + Parameters + ---------- + album : str + The other song album. + is_detailed_search : bool, optional + Whether the search is detailed or not, by default True. + + Returns + ------- + bool + Whether the song album is similar to another song album. + """ + return string_similarity(remove_parenthesis_content(self.album), + remove_parenthesis_content(album)) > 0.5 if is_detailed_search else True + + def is_live(self) -> bool: + """ + Returns whether the song is a live version. + + Returns + ------- + bool + Whether the song is a live version. + """ + return "live" in self.title.lower() def is_similar_duration(self, duration: int) -> bool: """ - Returns true if the duration of the song is within 10 seconds of the result + Returns True if the duration of the song is within 10 seconds of the other song duration. + + Parameters + ---------- + duration : int + The other song duration in seconds. + + Returns + ------- + bool + Whether the song duration is similar to another song duration. """ return abs(self.duration - duration) < 10 if self.duration is not None and duration is not None else True From 97f2d59c79691059cc0de3e2382d37c78a52bb43 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 10:57:51 +0200 Subject: [PATCH 19/33] document spotify.py --- src/main.py | 4 ++-- src/spotify.py | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main.py b/src/main.py index 5810206..14e589d 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,5 @@ """Main file for the application.""" -from spotify import setup_Spotipy +from spotify import setup_spotipy from sync import sync_spotify_to_ytmusic from yt_music import setup_YTMusic from cli.bcolors import bcolors @@ -7,7 +7,7 @@ from cli.operation import Operation if __name__ == "__main__": - spotify = setup_Spotipy() + spotify = setup_spotipy() ytmusic = setup_YTMusic() PLAYLIST_DIR = 'resources/playlists/' diff --git a/src/spotify.py b/src/spotify.py index 62f129a..0f62fd4 100644 --- a/src/spotify.py +++ b/src/spotify.py @@ -1,14 +1,24 @@ +"""This module contains functions to interact with the Spotify API.""" + import os + import spotipy -from spotipy.client import Spotify from dotenv import load_dotenv -from cli.bcolors import bcolors - +from spotipy.client import Spotify +from cli.bcolors import bcolors from playlist import Playlist, get_playlist_from_spotify -def setup_Spotipy() -> Spotify: +def setup_spotipy() -> Spotify: + """ + Sets up the Spotipy object for the Spotify API. + + Returns + ------- + Spotify + The Spotipy object. + """ load_dotenv() client_id: str = os.getenv('SPOTIPY_CLIENT_ID') client_secret: str = os.getenv('SPOTIPY_CLIENT_SECRET') @@ -23,9 +33,21 @@ def setup_Spotipy() -> Spotify: def get_playlists(spotify: Spotify) -> list[Playlist]: + """ + Gets all playlists from the current user. + + Parameters + ---------- + spotify : Spotify + The Spotipy object to use for the API calls. + + Returns + ------- + list[Playlist] + A list of Playlist objects. + """ current_user = spotify.current_user() - print( - f"{bcolors.OKCYAN}Searching for {current_user['display_name']}'s playlists...{bcolors.ENDC}") + print(f"{bcolors.OKCYAN}Searching for {current_user['display_name']}'s playlists...{bcolors.ENDC}") playlists_json = spotify.current_user_playlists() number_of_playlists = playlists_json['total'] @@ -35,8 +57,7 @@ def get_playlists(spotify: Spotify) -> list[Playlist]: playlists = [] for idx, playlist_json in enumerate(playlists_json['items']): playlist = get_playlist_from_spotify(playlist_json, spotify) - print( - f"{str(idx + 1).rjust(right_justify)}. {playlist.name} ({len(playlist.songs)} songs)") + print(f"{str(idx + 1).rjust(right_justify)}. {playlist.name} ({len(playlist.songs)} songs)") playlists.append(playlist) print() From 0fdf8598840edc01d34b32314d8d2f4ebcd12dc0 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 11:01:20 +0200 Subject: [PATCH 20/33] document sync.py --- src/sync.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/sync.py b/src/sync.py index 4cd1fd9..15ef199 100644 --- a/src/sync.py +++ b/src/sync.py @@ -1,18 +1,34 @@ -from cli.bcolors import bcolors -from ytmusicapi import YTMusic +"""This module contains the sync functions between different music streaming services""" from spotipy.client import Spotify +from ytmusicapi import YTMusic + +from cli.bcolors import bcolors from cli.menu import playlists_checkbox from cli.operation import Operation from spotify import get_playlists from yt_music import sync_playlist -def sync_spotify_to_ytmusic(spotify: Spotify, ytmusic: YTMusic): - print(f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}{Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY.value}...{bcolors.ENDC}") +def sync_spotify_to_ytmusic(spotify: Spotify, ytmusic: YTMusic) -> None: + """ + Syncs all Spotify playlists to the YouTube Music account. + + Parameters + ---------- + spotify : Spotify + The Spotify object to use for the API calls. + ytmusic : YTMusic + The YTMusic object to use for the API calls. + """ + print(f"{bcolors.HEADER}{bcolors.BOLD}{bcolors.UNDERLINE}" + f"{Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY.value}..." + f"{bcolors.ENDC}") playlists = get_playlists(spotify) playlists_to_sync = playlists_checkbox(playlists) for playlist in playlists_to_sync: sync_playlist(playlist, ytmusic) - print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}{len(playlists_to_sync)} playlist(s) have been synced to your YouTube Music account!{bcolors.ENDC}") + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}" + f"{len(playlists_to_sync)} playlist(s) have been synced to your YouTube Music account!" + f"{bcolors.ENDC}") From 4f2497fcd44832bcfacefde0157d37cda1b3521a Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 11:11:34 +0200 Subject: [PATCH 21/33] document yt_music.py --- src/main.py | 4 +-- src/yt_music.py | 80 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/main.py b/src/main.py index 14e589d..8511d9c 100644 --- a/src/main.py +++ b/src/main.py @@ -1,14 +1,14 @@ """Main file for the application.""" from spotify import setup_spotipy from sync import sync_spotify_to_ytmusic -from yt_music import setup_YTMusic +from yt_music import setup_ytmusic from cli.bcolors import bcolors from cli.menu import main_menu from cli.operation import Operation if __name__ == "__main__": spotify = setup_spotipy() - ytmusic = setup_YTMusic() + ytmusic = setup_ytmusic() PLAYLIST_DIR = 'resources/playlists/' diff --git a/src/yt_music.py b/src/yt_music.py index 0b8bd83..9b4ca29 100644 --- a/src/yt_music.py +++ b/src/yt_music.py @@ -1,17 +1,42 @@ -from ytmusicapi import YTMusic +"""This module contains functions to interact with the YoyTube API.""" + import os + +from dotenv import load_dotenv +from ytmusicapi import YTMusic + +from cli.bcolors import bcolors from playlist import Playlist from song import Song -from cli.bcolors import bcolors -from dotenv import load_dotenv def read_file(path: str) -> str: - with open(path, "r") as f: - return f.read() + """ + Reads a file and returns its content. + + Parameters + ---------- + path : str + The path to the file. + + Returns + ------- + str + The content of the file. + """ + with open(path, mode="r", encoding="utf-8") as file: + return file.read() -def setup_YTMusic() -> YTMusic: +def setup_ytmusic() -> YTMusic: + """ + Sets up the YTMusic object for the YouTube Music API. + + Returns + ------- + YTMusic + The YTMusic object. + """ load_dotenv() header_raw = read_file("./header_raw.txt") @@ -23,33 +48,52 @@ def setup_YTMusic() -> YTMusic: def search_matches(songs: list[Song], ytmusic: YTMusic) -> list[Song]: + """ + Searches for matches for the given songs on YouTube Music. + + Parameters + ---------- + songs : list[Song] + The songs to search for. + ytmusic : YTMusic + The YTMusic object to use for the API calls. + + Returns + ------- + list[Song] + list of songs with the best match on YouTube Music + """ songs_to_sync = [] for idx, song in enumerate(songs): print("Looking for a match for " + str(song)) search_result: Song = song.get_search_result(ytmusic) - if (search_result is None): - print( - f"{bcolors.WARNING}WARNING: No match found for track nr. {str(idx + 1)}: {str(song)}{bcolors.ENDC}") + if search_result is None: + print(f"{bcolors.WARNING}WARNING: No match found for track nr. {str(idx + 1)}: {str(song)}{bcolors.ENDC}") else: songs_to_sync.append(search_result) return songs_to_sync -def sync_playlist(playlist: Playlist, ytmusic: YTMusic): +def sync_playlist(playlist: Playlist, ytmusic: YTMusic) -> None: + """ + Syncs the given playlist to the current user's YouTube Music account. + Parameters + ---------- + playlist : Playlist + The playlist to sync. + ytmusic : YTMusic + The YTMusic object to use for the API calls. + """ print(f"{bcolors.BOLD}\nSearching matches for songs in \"{playlist.name}\" playlist{bcolors.ENDC}") # for each song in the Spotify playlist, search for a match on YouTube Music songs_to_sync = search_matches(playlist.songs, ytmusic) - print( - f"\nCreating playlist \"{playlist.name}\" in your YouTube Music account...") - playlistId = ytmusic.create_playlist( - playlist.name, playlist.description) + print(f"\nCreating playlist \"{playlist.name}\" in your YouTube Music account...") + playlist_id = ytmusic.create_playlist(playlist.name, playlist.description) - print( - f"Syncing playlist \"{playlist.name}\" in your YouTube Music account...") - ytmusic.add_playlist_items(playlistId, map( - lambda song: song.id, songs_to_sync)) + print(f"Syncing playlist \"{playlist.name}\" in your YouTube Music account...") + ytmusic.add_playlist_items(playlist_id, list(map(lambda song: song.id, songs_to_sync))) print(f"{bcolors.OKBLUE}\"{playlist.name}\" has been synced to your YouTube Music account!{bcolors.ENDC}") From ade3d69a6ea78be6ce6021520dfa7b6c643d234a Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 11:30:43 +0200 Subject: [PATCH 22/33] fix error path yt header --- .env.example | 2 +- src/main.py | 8 +++----- src/yt_music.py | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 5ea7e52..336d849 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ SPOTIPY_CLIENT_ID='paste your Spotify client id here' SPOTIPY_CLIENT_SECRET='paste your Spotify client secret here' SPOTIPY_REDIRECT_URI='paste your Spotify redirect uri here. E.g. http://localhost:8888/callback/' -HEADER_RAW='Paste your raw header here' \ No newline at end of file +YTMUSIC_HEADER_RAW='Paste your raw header here' \ No newline at end of file diff --git a/src/main.py b/src/main.py index 8511d9c..7182ec3 100644 --- a/src/main.py +++ b/src/main.py @@ -1,17 +1,15 @@ """Main file for the application.""" -from spotify import setup_spotipy -from sync import sync_spotify_to_ytmusic -from yt_music import setup_ytmusic from cli.bcolors import bcolors from cli.menu import main_menu from cli.operation import Operation +from spotify import setup_spotipy +from sync import sync_spotify_to_ytmusic +from yt_music import setup_ytmusic if __name__ == "__main__": spotify = setup_spotipy() ytmusic = setup_ytmusic() - PLAYLIST_DIR = 'resources/playlists/' - operation: Operation = main_menu() match operation: diff --git a/src/yt_music.py b/src/yt_music.py index 9b4ca29..c67b8d0 100644 --- a/src/yt_music.py +++ b/src/yt_music.py @@ -39,8 +39,8 @@ def setup_ytmusic() -> YTMusic: """ load_dotenv() - header_raw = read_file("./header_raw.txt") - header_json_path = "./resources/headers_auth.json" + header_raw = os.getenv("YTMUSIC_HEADER_RAW") + header_json_path = "./headers_auth.json" YTMusic.setup(filepath=header_json_path, headers_raw=header_raw) ytmusic = YTMusic(header_json_path) os.remove(header_json_path) From 644145d861864da90daa6a6558b01cdbbbd09ed0 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 16:03:29 +0200 Subject: [PATCH 23/33] fix get Spotify token --- src/spotify.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/spotify.py b/src/spotify.py index 0f62fd4..528fb6e 100644 --- a/src/spotify.py +++ b/src/spotify.py @@ -28,8 +28,11 @@ def setup_spotipy() -> Spotify: scope = "playlist-read-private playlist-read-collaborative user-library-read" # spotify authentication - oauth = spotipy.SpotifyOAuth(client_id, client_secret, redirect_uri, scope) - return spotipy.Spotify(auth_manager=oauth) + oauth = spotipy.SpotifyOAuth(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, + scope=scope) + token_dict = oauth.get_access_token() + token = token_dict['access_token'] + return spotipy.Spotify(auth=token) def get_playlists(spotify: Spotify) -> list[Playlist]: From fd49a816140a985c38444db75cca4f7657451cea Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 17:37:12 +0200 Subject: [PATCH 24/33] add first Song test --- src/__init__.py | 2 ++ src/utils/__init__.py | 2 ++ tests/__init__.py | 2 ++ tests/test_song.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 src/__init__.py create mode 100644 src/utils/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/test_song.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..3b3fe5e --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,2 @@ +import sys +sys.path.append('./src') diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..2427132 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,2 @@ +import sys +sys.path.append('./src/utils') \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b7be57f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +import sys +sys.path.append('.') \ No newline at end of file diff --git a/tests/test_song.py b/tests/test_song.py new file mode 100644 index 0000000..a08b4a4 --- /dev/null +++ b/tests/test_song.py @@ -0,0 +1,45 @@ +import sys +import unittest + +sys.path.insert(0, '../src') + +from src.song import Song + + +class TestSong: + def test_constructor(self): + song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + assert song.title == "Knockin' On Heaven's Door" + assert song.artists == ["Guns N' Roses"] + assert song.album == "Use Your Illusion II" + assert song.duration == 336 + assert not song.is_explicit + assert song.year == 1991 + + def test_get_search_query(self): + assert True + + def test_get_search_result(self): + assert True + + def test_is_similar(self): + assert True + + def test_is_similar_title(self): + assert True + + def test_is_similar_artists(self): + assert True + + def test_is_similar_album(self): + assert True + + def test_is_live(self): + assert True + + def test_is_similar_duration(self): + assert True + + +if __name__ == "__main__": + unittest.main() From 9a45ef1cb19377abdca82d2e320b764f15200dda Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 18:18:46 +0200 Subject: [PATCH 25/33] test for Song class --- src/song.py | 84 +++++++++++++++++++++++----------------------- tests/test_song.py | 51 ++++++++++++++++++---------- 2 files changed, 75 insertions(+), 60 deletions(-) diff --git a/src/song.py b/src/song.py index 09af702..6f62785 100644 --- a/src/song.py +++ b/src/song.py @@ -28,12 +28,12 @@ def join_artists_names(artists: list[str]) -> str: def get_song_from_ytmusic_json(args) -> Song: """ - Creates a Song object from a YTMusic song JSON object. + Creates a Song object from a YTMusic song1 JSON object. Parameters ---------- args : dict - The JSON object of the song. + The JSON object of the song1. Returns ------- @@ -53,12 +53,12 @@ def get_song_from_ytmusic_json(args) -> Song: def get_song_from_spotify_json(args) -> Song: """ - Creates a Song object from a Spotify song JSON object. + Creates a Song object from a Spotify song1 JSON object. Parameters ---------- args : dict - The JSON object of the song. + The JSON object of the song1. Returns ------- @@ -87,26 +87,26 @@ def get_song_from_spotify_json(args) -> Song: @dataclass class Song: """ - This class represents a song. + This class represents a song1. Attributes ---------- id : str - The ID of the song. + The ID of the song1. title : str - The title of the song. + The title of the song1. artists : list[str] The list of artists names. album : str The album name. duration : int - The duration of the song in seconds. + The duration of the song1 in seconds. is_explicit : bool - Whether the song is explicit or not. + Whether the song1 is explicit or not. year : int - The year of the song. + The year of the song1. id : str - The ID of the song. + The ID of the song1. """ def __init__(self, title: str, artists: list[str], album: str, duration: int = None, is_explicit: bool = False, @@ -117,19 +117,19 @@ def __init__(self, title: str, artists: list[str], album: str, duration: int = N Parameters ---------- title : str - The title of the song. + The title of the song1. artists : list[str] The list of artists names. album : str The album name. duration : int, optional - The duration of the song in seconds, by default None + The duration of the song1 in seconds, by default None is_explicit : bool, optional - Whether the song is explicit or not, by default False + Whether the song1 is explicit or not, by default False year : int, optional - The year of the song, by default None + The year of the song1, by default None id : str, optional - The ID of the song on Spotify or YouTube Music service, by default None + The ID of the song1 on Spotify or YouTube Music service, by default None """ self.id: str = id self.title: str = title @@ -141,7 +141,7 @@ def __init__(self, title: str, artists: list[str], album: str, duration: int = N def get_search_query(self) -> str: """ - Returns the search query for the song. + Returns the search query for the song1. Returns ------- @@ -150,15 +150,15 @@ def get_search_query(self) -> str: Examples -------- - >>> song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) - >>> song.get_search_query() + >>> song1 = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + >>> song1.get_search_query() 'Guns N' Roses - Knockin' On Heaven's Door (Use Your Illusion II)' """ return join_artists_names(self.artists) + " - " + self.title + " (" + self.album + ")" def get_search_result(self, ytmusic: YTMusic) -> Song: """ - Returns the best search result on YouTube Music for the song. + Returns the best search result on YouTube Music for the song1. Parameters ---------- @@ -186,19 +186,19 @@ def get_search_result(self, ytmusic: YTMusic) -> Song: def is_similar(self, other: Song, is_detailed_search: bool = True) -> bool: """ - Returns whether the song is similar to another song. + Returns whether the song1 is similar to another song1. Parameters ---------- other : Song - The other song. + The other song1. is_detailed_search : bool, optional Whether the search is detailed or not, by default True Returns ------- bool - Whether the song is similar to another song. + Whether the song1 is similar to another song1. """ return (self.is_similar_title(other.title, is_detailed_search) and self.is_similar_artists(other.artists, is_detailed_search) and @@ -210,19 +210,19 @@ def is_similar(self, other: Song, is_detailed_search: bool = True) -> bool: def is_similar_title(self, title: str, is_detailed_search: bool = True) -> bool: """ - Returns whether the song title is similar to another song title. + Returns whether the song1 title is similar to another song1 title. Parameters ---------- title : str - The other song title. + The other song1 title. is_detailed_search : bool, optional Whether the search is detailed or not, by default True Returns ------- bool - Whether the song title is similar to another song title. + Whether the song1 title is similar to another song1 title. """ self_title = self.title if is_detailed_search else remove_parenthesis_content( get_string_before_dash(self.title)) @@ -233,12 +233,12 @@ def is_similar_title(self, title: str, is_detailed_search: bool = True) -> bool: def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True) -> bool: """ - Returns whether the song artists are similar to another song artists. + Returns whether the song1 artists are similar to another song1 artists. Parameters ---------- artists : list[str] - The other song artists. + The other song1 artists. is_detailed_search : bool, optional Whether the search is detailed or not, by default True. If True, the artists names must be in the same order. @@ -247,18 +247,18 @@ def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True Returns ------- bool - Whether the song artists are similar to another song artists. + Whether the song1 artists are similar to another song1 artists. Examples -------- - >>> song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) - >>> song.is_similar_artists(["Guns N' Roses"], True) + >>> song1 = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + >>> song1.is_similar_artists(["Guns N' Roses"], True) True - >>> song.is_similar_artists(["Guns N' Roses"], False) + >>> song1.is_similar_artists(["Guns N' Roses"], False) True - >>> song.is_similar_artists(["Guns N' Roses", "Axl Rose"], True) + >>> song1.is_similar_artists(["Guns N' Roses", "Axl Rose"], True) False - >>> song.is_similar_artists(["Guns N' Roses", "Axl Rose"], False) + >>> song1.is_similar_artists(["Guns N' Roses", "Axl Rose"], False) True """ if is_detailed_search: @@ -269,47 +269,47 @@ def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True def is_similar_album(self, album: str, is_detailed_search: bool = True) -> bool: """ - Returns whether the song album is similar to another song album. + Returns whether the song1 album is similar to another song1 album. Parameters ---------- album : str - The other song album. + The other song1 album. is_detailed_search : bool, optional Whether the search is detailed or not, by default True. Returns ------- bool - Whether the song album is similar to another song album. + Whether the song1 album is similar to another song1 album. """ return string_similarity(remove_parenthesis_content(self.album), remove_parenthesis_content(album)) > 0.5 if is_detailed_search else True def is_live(self) -> bool: """ - Returns whether the song is a live version. + Returns whether the song1 is a live version. Returns ------- bool - Whether the song is a live version. + Whether the song1 is a live version. """ return "live" in self.title.lower() def is_similar_duration(self, duration: int) -> bool: """ - Returns True if the duration of the song is within 10 seconds of the other song duration. + Returns True if the duration of the song1 is within 10 seconds of the other song1 duration. Parameters ---------- duration : int - The other song duration in seconds. + The other song1 duration in seconds. Returns ------- bool - Whether the song duration is similar to another song duration. + Whether the song1 duration is similar to another song1 duration. """ return abs(self.duration - duration) < 10 if self.duration is not None and duration is not None else True diff --git a/tests/test_song.py b/tests/test_song.py index a08b4a4..81c7e33 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -6,39 +6,54 @@ from src.song import Song -class TestSong: +class TestSong(unittest.TestCase): + song1 = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + song2 = Song("Layla (Acoustic Live)", ["Eric Clapton"], "Derek and the Dominos", 480, False, 1970) + song3 = Song("Blowin' in the Wind", ["Bob Dylan"], "The Freewheelin' Bob Dylan", 166, False, 1963) + song4 = Song("Blowin' in the Wind", ["Peter, Paul and Mary"], "Moving", 180, False, 1963) + def test_constructor(self): - song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) - assert song.title == "Knockin' On Heaven's Door" - assert song.artists == ["Guns N' Roses"] - assert song.album == "Use Your Illusion II" - assert song.duration == 336 - assert not song.is_explicit - assert song.year == 1991 + assert self.song1.title == "Knockin' On Heaven's Door" + assert self.song1.artists == ["Guns N' Roses"] + assert self.song1.album == "Use Your Illusion II" + assert self.song1.duration == 336 + assert not self.song1.is_explicit + assert self.song1.year == 1991 def test_get_search_query(self): - assert True - - def test_get_search_result(self): - assert True + assert self.song1.get_search_query() == "Guns N' Roses - Knockin' On Heaven's Door (Use Your Illusion II)" + assert not self.song1.get_search_query() == "Guns N' Roses - Knockin' On Heaven's Door Use Your Illusion II" + assert self.song3.get_search_query() == "Bob Dylan - Blowin' in the Wind (The Freewheelin' Bob Dylan)" + assert self.song4.get_search_query() == "Peter, Paul and Mary - Blowin' in the Wind (Moving)" def test_is_similar(self): - assert True + assert not self.song3.is_similar(self.song4) + assert self.song3.is_similar(self.song3) def test_is_similar_title(self): - assert True + assert self.song3.is_similar_title(self.song4.title) + assert not self.song3.is_similar_title(self.song1.title) def test_is_similar_artists(self): - assert True + assert self.song1.is_similar_artists(self.song1.artists) + assert not self.song3.is_similar_artists(self.song4.artists) + assert not self.song3.is_similar_artists(["Bob Dylan", "Peter, Paul and Mary"], is_detailed_search=True) + assert self.song3.is_similar_artists(["Bob Dylan", "Peter, Paul and Mary"], is_detailed_search=False) def test_is_similar_album(self): - assert True + assert self.song1.is_similar_album(self.song1.album) + assert not self.song3.is_similar_album(self.song4.album) def test_is_live(self): - assert True + assert self.song2.is_live() + assert not self.song1.is_live() def test_is_similar_duration(self): - assert True + assert self.song1.is_similar_duration(self.song1.duration) + assert self.song1.is_similar_duration(self.song1.duration + 1) + assert self.song1.is_similar_duration(self.song1.duration - 1) + assert not self.song2.is_similar_duration(self.song2.duration + 10) + assert not self.song2.is_similar_duration(self.song2.duration - 10) if __name__ == "__main__": From 2af16809776224079e5cca0ab2264352abaafc00 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 18:20:14 +0200 Subject: [PATCH 26/33] add pytest and mock to requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bbd4ff2..ae4fc63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ python-dotenv~=1.0 chardet~=5.1 spotipy~=2.22 inquirer~=3.1 - +pytest~=7.2 +mock~=5.0 From c1560305fd707ed4d4637d2b63221b81031f1b56 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 19:45:12 +0200 Subject: [PATCH 27/33] fix keys not present in YT song json --- src/song.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/song.py b/src/song.py index 6f62785..f2b0324 100644 --- a/src/song.py +++ b/src/song.py @@ -1,5 +1,4 @@ """This module contains the Song class and its methods.""" - from __future__ import annotations from dataclasses import dataclass @@ -26,7 +25,7 @@ def join_artists_names(artists: list[str]) -> str: return ", ".join(artists) -def get_song_from_ytmusic_json(args) -> Song: +def get_song_from_ytmusic_json(args: dict) -> Song: """ Creates a Song object from a YTMusic song1 JSON object. @@ -42,10 +41,10 @@ def get_song_from_ytmusic_json(args) -> Song: """ title = args["title"] artist = list(map(lambda a: a["name"], args["artists"])) - album = args["album"]["name"] if args["album"] is not None else None + album = args["album"]["name"] if "album" in args.keys() and args["album"] is not None else None duration = args["duration_seconds"] - is_explicit = args["isExplicit"] - year = args["year"] + is_explicit = args["isExplicit"] if "isExplicit" in args.keys() else False + year = args["year"] if "year" in args.keys() else None id = args["videoId"] return Song(title, artist, album, duration, is_explicit, year, id) From 0f29e52926a2736200c3ed855637ae883e06b6df Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 20:03:03 +0200 Subject: [PATCH 28/33] year is converted to int from spotify json --- src/song.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/song.py b/src/song.py index f2b0324..f84c096 100644 --- a/src/song.py +++ b/src/song.py @@ -72,9 +72,9 @@ def get_song_from_spotify_json(args) -> Song: match args["album"]["release_date_precision"]: case "day" | "month": - year = args["album"]["release_date"].split("-")[0] + year = int(args["album"]["release_date"].split("-")[0]) case "year": - year = args["album"]["release_date"] + year = int(args["album"]["release_date"]) case _: year = None From 2afa6505232374745f953d8d191c6d30d6fef917 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 20:13:25 +0200 Subject: [PATCH 29/33] add is_similar_year function --- src/song.py | 102 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/src/song.py b/src/song.py index f84c096..ee2f52d 100644 --- a/src/song.py +++ b/src/song.py @@ -27,12 +27,12 @@ def join_artists_names(artists: list[str]) -> str: def get_song_from_ytmusic_json(args: dict) -> Song: """ - Creates a Song object from a YTMusic song1 JSON object. + Creates a Song object from a YTMusic song JSON object. Parameters ---------- args : dict - The JSON object of the song1. + The JSON object of the song. Returns ------- @@ -52,12 +52,12 @@ def get_song_from_ytmusic_json(args: dict) -> Song: def get_song_from_spotify_json(args) -> Song: """ - Creates a Song object from a Spotify song1 JSON object. + Creates a Song object from a Spotify song JSON object. Parameters ---------- args : dict - The JSON object of the song1. + The JSON object of the song. Returns ------- @@ -86,26 +86,26 @@ def get_song_from_spotify_json(args) -> Song: @dataclass class Song: """ - This class represents a song1. + This class represents a song. Attributes ---------- id : str - The ID of the song1. + The ID of the song. title : str - The title of the song1. + The title of the song. artists : list[str] The list of artists names. album : str The album name. duration : int - The duration of the song1 in seconds. + The duration of the song in seconds. is_explicit : bool - Whether the song1 is explicit or not. + Whether the song is explicit or not. year : int - The year of the song1. + The year of the song. id : str - The ID of the song1. + The ID of the song. """ def __init__(self, title: str, artists: list[str], album: str, duration: int = None, is_explicit: bool = False, @@ -116,19 +116,19 @@ def __init__(self, title: str, artists: list[str], album: str, duration: int = N Parameters ---------- title : str - The title of the song1. + The title of the song. artists : list[str] The list of artists names. album : str The album name. duration : int, optional - The duration of the song1 in seconds, by default None + The duration of the song in seconds, by default None is_explicit : bool, optional - Whether the song1 is explicit or not, by default False + Whether the song is explicit or not, by default False year : int, optional - The year of the song1, by default None + The year of the song, by default None id : str, optional - The ID of the song1 on Spotify or YouTube Music service, by default None + The ID of the song on Spotify or YouTube Music service, by default None """ self.id: str = id self.title: str = title @@ -140,7 +140,7 @@ def __init__(self, title: str, artists: list[str], album: str, duration: int = N def get_search_query(self) -> str: """ - Returns the search query for the song1. + Returns the search query for the song. Returns ------- @@ -149,15 +149,15 @@ def get_search_query(self) -> str: Examples -------- - >>> song1 = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) - >>> song1.get_search_query() + >>> song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + >>> song.get_search_query() 'Guns N' Roses - Knockin' On Heaven's Door (Use Your Illusion II)' """ return join_artists_names(self.artists) + " - " + self.title + " (" + self.album + ")" def get_search_result(self, ytmusic: YTMusic) -> Song: """ - Returns the best search result on YouTube Music for the song1. + Returns the best search result on YouTube Music for the song. Parameters ---------- @@ -185,19 +185,19 @@ def get_search_result(self, ytmusic: YTMusic) -> Song: def is_similar(self, other: Song, is_detailed_search: bool = True) -> bool: """ - Returns whether the song1 is similar to another song1. + Returns whether the song is similar to another song. Parameters ---------- other : Song - The other song1. + The other song. is_detailed_search : bool, optional Whether the search is detailed or not, by default True Returns ------- bool - Whether the song1 is similar to another song1. + Whether the song is similar to another song. """ return (self.is_similar_title(other.title, is_detailed_search) and self.is_similar_artists(other.artists, is_detailed_search) and @@ -205,23 +205,23 @@ def is_similar(self, other: Song, is_detailed_search: bool = True) -> bool: self.is_live() == other.is_live() and self.is_similar_duration(other.duration) and self.is_explicit == other.is_explicit and - (self.year == other.year if self.year is not None and other.year is not None else True)) + self.is_similar_year(other.year)) def is_similar_title(self, title: str, is_detailed_search: bool = True) -> bool: """ - Returns whether the song1 title is similar to another song1 title. + Returns whether the song title is similar to another song title. Parameters ---------- title : str - The other song1 title. + The other song title. is_detailed_search : bool, optional Whether the search is detailed or not, by default True Returns ------- bool - Whether the song1 title is similar to another song1 title. + Whether the song title is similar to another song title. """ self_title = self.title if is_detailed_search else remove_parenthesis_content( get_string_before_dash(self.title)) @@ -232,12 +232,12 @@ def is_similar_title(self, title: str, is_detailed_search: bool = True) -> bool: def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True) -> bool: """ - Returns whether the song1 artists are similar to another song1 artists. + Returns whether the song artists are similar to another song artists. Parameters ---------- artists : list[str] - The other song1 artists. + The other song artists. is_detailed_search : bool, optional Whether the search is detailed or not, by default True. If True, the artists names must be in the same order. @@ -246,18 +246,18 @@ def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True Returns ------- bool - Whether the song1 artists are similar to another song1 artists. + Whether the song artists are similar to another song artists. Examples -------- - >>> song1 = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) - >>> song1.is_similar_artists(["Guns N' Roses"], True) + >>> song = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) + >>> song.is_similar_artists(["Guns N' Roses"], True) True - >>> song1.is_similar_artists(["Guns N' Roses"], False) + >>> song.is_similar_artists(["Guns N' Roses"], False) True - >>> song1.is_similar_artists(["Guns N' Roses", "Axl Rose"], True) + >>> song.is_similar_artists(["Guns N' Roses", "Axl Rose"], True) False - >>> song1.is_similar_artists(["Guns N' Roses", "Axl Rose"], False) + >>> song.is_similar_artists(["Guns N' Roses", "Axl Rose"], False) True """ if is_detailed_search: @@ -268,49 +268,65 @@ def is_similar_artists(self, artists: list[str], is_detailed_search: bool = True def is_similar_album(self, album: str, is_detailed_search: bool = True) -> bool: """ - Returns whether the song1 album is similar to another song1 album. + Returns whether the song album is similar to another song album. Parameters ---------- album : str - The other song1 album. + The other song album. is_detailed_search : bool, optional Whether the search is detailed or not, by default True. Returns ------- bool - Whether the song1 album is similar to another song1 album. + Whether the song album is similar to another song album. """ return string_similarity(remove_parenthesis_content(self.album), remove_parenthesis_content(album)) > 0.5 if is_detailed_search else True def is_live(self) -> bool: """ - Returns whether the song1 is a live version. + Returns whether the song is a live version. Returns ------- bool - Whether the song1 is a live version. + Whether the song is a live version. """ return "live" in self.title.lower() def is_similar_duration(self, duration: int) -> bool: """ - Returns True if the duration of the song1 is within 10 seconds of the other song1 duration. + Returns wheter the duration of the song is within 10 seconds of the other song duration. Parameters ---------- duration : int - The other song1 duration in seconds. + The other song duration in seconds. Returns ------- bool - Whether the song1 duration is similar to another song1 duration. + Whether the song duration is similar to another song duration. """ return abs(self.duration - duration) < 10 if self.duration is not None and duration is not None else True + def is_similar_year(self, year): + """ + Returns whether the song release year is similar to another song release year. + + Parameters + ---------- + year : int + The other song release year. + + Returns + ------- + bool + Whether the other song release year is similar to another song release year. + """ + return self.year == year if self.year is not None and year is not None else True + def __str__(self): return join_artists_names(self.artists) + " - " + self.title + " (" + self.album + ")" From f35d7ffe0b4d68f67ce6cb2f16ba5e76ddf5bfb8 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 20:17:17 +0200 Subject: [PATCH 30/33] add file_utils.py --- src/utils/file_utils.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/utils/file_utils.py diff --git a/src/utils/file_utils.py b/src/utils/file_utils.py new file mode 100644 index 0000000..a61364c --- /dev/null +++ b/src/utils/file_utils.py @@ -0,0 +1,36 @@ +import json + + +def read_file(path: str) -> str: + """ + Reads a file and returns its content. + + Parameters + ---------- + path : str + The path to the file. + + Returns + ------- + str + The content of the file. + """ + with open(path, mode="r", encoding="utf-8") as file: + return file.read() + + +def get_json_from_file(path: str) -> json: + """ + Returns the JSON object from a json file. + + Parameters + ---------- + path : str + The path to the file. + + Returns + ------- + dict + The JSON from the file. + """ + return json.loads(read_file(path)) From ac9dd60b1b4e2022be6b7f3f63a51a3c3f64e421 Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Tue, 28 Mar 2023 20:18:26 +0200 Subject: [PATCH 31/33] test functions which convert Spotify and YT json to a Song object --- .../resources/spotify_track_json_output.json | 73 ++++++++++++ .../resources/ytmusic_track_json_output.json | 37 +++++++ tests/test_song.py | 104 ++++++++++++------ 3 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 tests/resources/spotify_track_json_output.json create mode 100644 tests/resources/ytmusic_track_json_output.json diff --git a/tests/resources/spotify_track_json_output.json b/tests/resources/spotify_track_json_output.json new file mode 100644 index 0000000..3ad28ee --- /dev/null +++ b/tests/resources/spotify_track_json_output.json @@ -0,0 +1,73 @@ +{ + "album": { + "album_group": "album", + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/3qm84nBOXUEQ2vnTfUTTFC" + }, + "href": "https://api.spotify.com/v1/artists/3qm84nBOXUEQ2vnTfUTTFC", + "id": "3qm84nBOXUEQ2vnTfUTTFC", + "name": "Guns N' Roses", + "type": "artist", + "uri": "spotify:artist:3qm84nBOXUEQ2vnTfUTTFC" + }], + "available_markets": ["AD", "AE", "AG", "AL", "AM", "AO", "AR", "AT", "AU", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BY", "BZ", "CA", "CD", "CG", "CH", "CI", "CL", "CM", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ES", "ET", "FI", "FJ", "FM", "FR", "GA", "GB", "GD", "GE", "GH", "GM", "GN", "GQ", "GR", "GT", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MN", "MO", "MR", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NE", "NG", "NI", "NL", "NO", "NP", "NR", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PS", "PT", "PW", "PY", "QA", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SZ", "TD", "TG", "TH", "TJ", "TL", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VN", "VU", "WS", "XK", "ZA", "ZM", "ZW"], + "external_urls": { + "spotify": "https://open.spotify.com/album/00eiw4KOJZ7eC3NBEpmH4C" + }, + "href": "https://api.spotify.com/v1/albums/00eiw4KOJZ7eC3NBEpmH4C", + "id": "00eiw4KOJZ7eC3NBEpmH4C", + "images": [{ + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b27392d21aef6c0d288cc4c05973", + "width": 640 + }, { + "height": 300, + "url": "https://i.scdn.co/image/ab67616d00001e0292d21aef6c0d288cc4c05973", + "width": 300 + }, { + "height": 64, + "url": "https://i.scdn.co/image/ab67616d0000485192d21aef6c0d288cc4c05973", + "width": 64 + }], + "is_playable": true, + "name": "Use Your Illusion II", + "release_date": "1991-09-18", + "release_date_precision": "day", + "total_tracks": 14, + "type": "album", + "uri": "spotify:album:00eiw4KOJZ7eC3NBEpmH4C" + }, + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/3qm84nBOXUEQ2vnTfUTTFC" + }, + "href": "https://api.spotify.com/v1/artists/3qm84nBOXUEQ2vnTfUTTFC", + "id": "3qm84nBOXUEQ2vnTfUTTFC", + "name": "Guns N' Roses", + "type": "artist", + "uri": "spotify:artist:3qm84nBOXUEQ2vnTfUTTFC" + }], + "available_markets": ["AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", "LY", "TJ", "VE", "ET", "XK"], + "disc_number": 1, + "duration_ms": 336000, + "episode": false, + "explicit": false, + "external_ids": { + "isrc": "USGF19142004" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/4JiEyzf0Md7KEFFGWDDdCr" + }, + "href": "https://api.spotify.com/v1/tracks/4JiEyzf0Md7KEFFGWDDdCr", + "id": "4JiEyzf0Md7KEFFGWDDdCr", + "is_local": false, + "name": "Knockin' On Heaven's Door", + "popularity": 78, + "preview_url": null, + "track": true, + "track_number": 4, + "type": "track", + "uri": "spotify:track:4JiEyzf0Md7KEFFGWDDdCr" +} \ No newline at end of file diff --git a/tests/resources/ytmusic_track_json_output.json b/tests/resources/ytmusic_track_json_output.json new file mode 100644 index 0000000..91a7e40 --- /dev/null +++ b/tests/resources/ytmusic_track_json_output.json @@ -0,0 +1,37 @@ +{ + "category": "Songs", + "resultType": "song", + "title": "Knockin' On Heaven's Door", + "album": { + "name": "Use Your Illusion II", + "id": "MPREb_BWthKEBKL9Y" + }, + "feedbackTokens": { + "add": "AB9zfpLmS0KgCFYu_APZuZ0nLve6ym2Y3s0im9uU0_EILH6mtRFThgC5FCcV8RuooJDSx6-PnNW_MJcB8u-ZTRotLZ2hpUajsg", + "remove": "AB9zfpJ6eTjBcRS9C1qmaqvZ7cCaL0_8Hk1mNTMJ2hQIzCiMxCB258Ko124YNpdg9qhZ2U78F0eQWGETRZi8cdu-CnNfuL7rrw" + }, + "videoId": "f8OHybVhQwc", + "videoType": "MUSIC_VIDEO_TYPE_ATV", + "duration": "5:36", + "year": null, + "artists": [ + { + "name": "Guns N' Roses", + "id": "UCSLbbBoUqpin6BE34whSOvA" + } + ], + "duration_seconds": 336, + "isExplicit": false, + "thumbnails": [ + { + "url": "https://lh3.googleusercontent.com/Q7XFwlxOBgvsK4ZPyK855aeG5piiewmvIWhN_6aoAfe4hn0uqKQ2vzEOLr-CRLuohr0RwuIo7p4rCdE=w60-h60-l90-rj", + "width": 60, + "height": 60 + }, + { + "url": "https://lh3.googleusercontent.com/Q7XFwlxOBgvsK4ZPyK855aeG5piiewmvIWhN_6aoAfe4hn0uqKQ2vzEOLr-CRLuohr0RwuIo7p4rCdE=w120-h120-l90-rj", + "width": 120, + "height": 120 + } + ] +} \ No newline at end of file diff --git a/tests/test_song.py b/tests/test_song.py index 81c7e33..20f522b 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -1,59 +1,99 @@ +import json import sys import unittest sys.path.insert(0, '../src') -from src.song import Song +from src.song import Song, join_artists_names, get_song_from_spotify_json, get_song_from_ytmusic_json +from src.utils.file_utils import get_json_from_file + +song1 = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) +song2 = Song("Layla (Acoustic Live)", ["Eric Clapton"], "Derek and the Dominos", 480, False, 1970) +song3 = Song("Blowin' in the Wind", ["Bob Dylan"], "The Freewheelin' Bob Dylan", 166, False, 1963) +song4 = Song("Blowin' in the Wind", ["Peter, Paul and Mary"], "Moving", 180, False, 1963) class TestSong(unittest.TestCase): - song1 = Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991) - song2 = Song("Layla (Acoustic Live)", ["Eric Clapton"], "Derek and the Dominos", 480, False, 1970) - song3 = Song("Blowin' in the Wind", ["Bob Dylan"], "The Freewheelin' Bob Dylan", 166, False, 1963) - song4 = Song("Blowin' in the Wind", ["Peter, Paul and Mary"], "Moving", 180, False, 1963) def test_constructor(self): - assert self.song1.title == "Knockin' On Heaven's Door" - assert self.song1.artists == ["Guns N' Roses"] - assert self.song1.album == "Use Your Illusion II" - assert self.song1.duration == 336 - assert not self.song1.is_explicit - assert self.song1.year == 1991 + assert song1.title == "Knockin' On Heaven's Door" + assert song1.artists == ["Guns N' Roses"] + assert song1.album == "Use Your Illusion II" + assert song1.duration == 336 + assert not song1.is_explicit + assert song1.year == 1991 def test_get_search_query(self): - assert self.song1.get_search_query() == "Guns N' Roses - Knockin' On Heaven's Door (Use Your Illusion II)" - assert not self.song1.get_search_query() == "Guns N' Roses - Knockin' On Heaven's Door Use Your Illusion II" - assert self.song3.get_search_query() == "Bob Dylan - Blowin' in the Wind (The Freewheelin' Bob Dylan)" - assert self.song4.get_search_query() == "Peter, Paul and Mary - Blowin' in the Wind (Moving)" + assert song1.get_search_query() == "Guns N' Roses - Knockin' On Heaven's Door (Use Your Illusion II)" + assert not song1.get_search_query() == "Guns N' Roses - Knockin' On Heaven's Door Use Your Illusion II" + assert song3.get_search_query() == "Bob Dylan - Blowin' in the Wind (The Freewheelin' Bob Dylan)" + assert song4.get_search_query() == "Peter, Paul and Mary - Blowin' in the Wind (Moving)" def test_is_similar(self): - assert not self.song3.is_similar(self.song4) - assert self.song3.is_similar(self.song3) + assert not song3.is_similar(song4) + assert song3.is_similar(song3) def test_is_similar_title(self): - assert self.song3.is_similar_title(self.song4.title) - assert not self.song3.is_similar_title(self.song1.title) + assert song3.is_similar_title(song4.title) + assert not song3.is_similar_title(song1.title) def test_is_similar_artists(self): - assert self.song1.is_similar_artists(self.song1.artists) - assert not self.song3.is_similar_artists(self.song4.artists) - assert not self.song3.is_similar_artists(["Bob Dylan", "Peter, Paul and Mary"], is_detailed_search=True) - assert self.song3.is_similar_artists(["Bob Dylan", "Peter, Paul and Mary"], is_detailed_search=False) + assert song1.is_similar_artists(song1.artists) + assert not song3.is_similar_artists(song4.artists) + assert not song3.is_similar_artists(["Bob Dylan", "Peter, Paul and Mary"], is_detailed_search=True) + assert song3.is_similar_artists(["Bob Dylan", "Peter, Paul and Mary"], is_detailed_search=False) def test_is_similar_album(self): - assert self.song1.is_similar_album(self.song1.album) - assert not self.song3.is_similar_album(self.song4.album) + assert song1.is_similar_album(song1.album) + assert not song3.is_similar_album(song4.album) def test_is_live(self): - assert self.song2.is_live() - assert not self.song1.is_live() + assert song2.is_live() + assert not song1.is_live() def test_is_similar_duration(self): - assert self.song1.is_similar_duration(self.song1.duration) - assert self.song1.is_similar_duration(self.song1.duration + 1) - assert self.song1.is_similar_duration(self.song1.duration - 1) - assert not self.song2.is_similar_duration(self.song2.duration + 10) - assert not self.song2.is_similar_duration(self.song2.duration - 10) + assert song1.is_similar_duration(song1.duration) + assert song1.is_similar_duration(song1.duration + 1) + assert song1.is_similar_duration(song1.duration - 1) + assert not song2.is_similar_duration(song2.duration + 10) + assert not song2.is_similar_duration(song2.duration - 10) + + def test_is_similar_year(self): + assert song1.is_similar_year(song1.year) + assert not song1.is_similar_year(song1.year + 1) + assert not song1.is_similar_year(song1.year - 1) + assert song1.is_similar_year(None) + + + +def test_join_artists_names(): + assert join_artists_names([]) == "" + assert join_artists_names(["Bob Dylan"]) == "Bob Dylan" + assert join_artists_names(["Bob Dylan", "Eric Clapton"]) == "Bob Dylan, Eric Clapton" + + +def test_get_song_from_spotify_json(): + track_json: json = get_json_from_file("./tests/resources/spotify_track_json_output.json") + song_from_json: Song = get_song_from_spotify_json(track_json) + + assert song1.is_similar_title(song_from_json.title) + assert song1.is_similar_artists(song_from_json.artists) + assert song1.is_similar_album(song_from_json.album) + assert song1.is_similar_duration(song_from_json.duration) + assert song1.is_explicit == song_from_json.is_explicit + assert song1.is_similar_year(song_from_json.year) + + +def test_get_song_from_ytmusic_json(): + track_json: json = get_json_from_file("./tests/resources/ytmusic_track_json_output.json") + song_from_json: Song = get_song_from_ytmusic_json(track_json) + + assert song1.is_similar_title(song_from_json.title) + assert song1.is_similar_artists(song_from_json.artists) + assert song1.is_similar_album(song_from_json.album) + assert song1.is_similar_duration(song_from_json.duration) + assert song1.is_explicit == song_from_json.is_explicit + assert song1.is_similar_year(song_from_json.year) if __name__ == "__main__": From a3390006c2a4e8b7c2892612fec64229518bf4ee Mon Sep 17 00:00:00 2001 From: Federico Pellegatta <48312200+federicopellegatta@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:43:12 +0200 Subject: [PATCH 32/33] Create tests.yml --- .github/workflows/tests.yml | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a10fe0a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Tests + +on: + push: + branches: '**' + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest From a6fcdca13fda6693f7c981cf1349bbf7f6e721ab Mon Sep 17 00:00:00 2001 From: Federico Pellegatta Date: Wed, 29 Mar 2023 18:59:12 +0200 Subject: [PATCH 33/33] create Playlist test --- tests/test_playlist.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/test_playlist.py diff --git a/tests/test_playlist.py b/tests/test_playlist.py new file mode 100644 index 0000000..7dee084 --- /dev/null +++ b/tests/test_playlist.py @@ -0,0 +1,32 @@ +import sys +import unittest + +sys.path.insert(0, '../src') + +from src.playlist import Playlist +from src.song import Song + +playlist = Playlist("My Playlist", "Playlist id", "My Playlist Image URL", "My Playlist Description", [ + Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991), + Song("Layla (Acoustic Live)", ["Eric Clapton"], "Derek and the Dominos", 480, False, 1970), + Song("Blowin' in the Wind", ["Bob Dylan"], "The Freewheelin' Bob Dylan", 166, False, 1963), + Song("Blowin' in the Wind", ["Peter, Paul and Mary"], "Moving", 180, False, 1963) +]) + + +class TestPlaylist(unittest.TestCase): + def test_constructor(self): + assert playlist.name == "My Playlist" + assert playlist.id == "Playlist id" + assert playlist.description == "My Playlist Description" + assert playlist.image == "My Playlist Image URL" + assert playlist.songs == [ + Song("Knockin' On Heaven's Door", ["Guns N' Roses"], "Use Your Illusion II", 336, False, 1991), + Song("Layla (Acoustic Live)", ["Eric Clapton"], "Derek and the Dominos", 480, False, 1970), + Song("Blowin' in the Wind", ["Bob Dylan"], "The Freewheelin' Bob Dylan", 166, False, 1963), + Song("Blowin' in the Wind", ["Peter, Paul and Mary"], "Moving", 180, False, 1963) + ] + + +if __name__ == '__main__': + unittest.main()