Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync Spotify playlists -> YT Music #1

Draft
wants to merge 35 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
26af6a4
Add spotipy as requirement
federicopellegatta Mar 21, 2023
8d9c8c6
add chardet as requirement
federicopellegatta Mar 21, 2023
034d2cb
add cli menu
federicopellegatta Mar 21, 2023
8f548dd
add spotipy env variables
federicopellegatta Mar 21, 2023
9d353a4
add spotify authentication
federicopellegatta Mar 21, 2023
d6c686a
add spotify.py which implements get_playlists
federicopellegatta Mar 21, 2023
0faf3a4
implement sync Spotify -> YT and disable other operations
federicopellegatta Mar 22, 2023
c2f1494
Remove header_row.txt
federicopellegatta Mar 22, 2023
bd3f27b
update gitignore
federicopellegatta Mar 22, 2023
6c5d2b7
rename menu -> main_menu
federicopellegatta Mar 22, 2023
77bb817
add playlists checkbox
federicopellegatta Mar 22, 2023
6e3a784
Merge branch 'master' into get-spotify-playlists
federicopellegatta Mar 22, 2023
ac10280
move sync operations to sync.py
federicopellegatta Mar 22, 2023
46e3b37
clean code and remove method related to sync from csv
federicopellegatta Mar 22, 2023
42524f4
fix imports
federicopellegatta Mar 24, 2023
e9e9cb6
add menu to manage case no playlist selected
federicopellegatta Mar 24, 2023
d0d31db
update gitignore (ignoring .idea/)
federicopellegatta Mar 28, 2023
1b2ba26
document playlist.py
federicopellegatta Mar 28, 2023
7994f92
document song.py
federicopellegatta Mar 28, 2023
97f2d59
document spotify.py
federicopellegatta Mar 28, 2023
0fdf859
document sync.py
federicopellegatta Mar 28, 2023
4f2497f
document yt_music.py
federicopellegatta Mar 28, 2023
ade3d69
fix error path yt header
federicopellegatta Mar 28, 2023
644145d
fix get Spotify token
federicopellegatta Mar 28, 2023
fd49a81
add first Song test
federicopellegatta Mar 28, 2023
9a45ef1
test for Song class
federicopellegatta Mar 28, 2023
2af1680
add pytest and mock to requirements.txt
federicopellegatta Mar 28, 2023
c156030
fix keys not present in YT song json
federicopellegatta Mar 28, 2023
0f29e52
year is converted to int from spotify json
federicopellegatta Mar 28, 2023
2afa650
add is_similar_year function
federicopellegatta Mar 28, 2023
f35d7ff
add file_utils.py
federicopellegatta Mar 28, 2023
ac9dd60
test functions which convert Spotify and YT json to a Song object
federicopellegatta Mar 28, 2023
a339000
Create tests.yml
federicopellegatta Mar 29, 2023
6b87529
Merge pull request #2 from federicopellegatta/tests-action
federicopellegatta Mar 29, 2023
a6fcdca
create Playlist test
federicopellegatta Mar 29, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
HEADER_RAW='Paste your raw header here'
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/'
YTMUSIC_HEADER_RAW='Paste your raw header here'
39 changes: 39 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ ENV/
env.bak/
venv.bak/

# ignore header_raw.txt
header_raw.txt

# do not ignore env example file
!.env.example

Expand Down Expand Up @@ -160,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/
.idea/
6 changes: 5 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
ytmusicapi~=0.25
pandas~=1.4
python-dotenv~=1.0
chardet~=5.1
chardet~=5.1
spotipy~=2.22
inquirer~=3.1
pytest~=7.2
mock~=5.0
43 changes: 0 additions & 43 deletions resources/playlists/take_it_easy_rock.csv

This file was deleted.

2 changes: 2 additions & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import sys
sys.path.append('./src')
File renamed without changes.
52 changes: 52 additions & 0 deletions src/cli/menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import inquirer
from cli.operation import Operation
from cli.bcolors import bcolors
from playlist import Playlist


def main_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"])


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

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"]])
6 changes: 6 additions & 0 deletions src/cli/operation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from enum import Enum


class Operation(Enum):
SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY = 'Sync YouTube Music playlists from Spotify'
SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE = 'Sync Spotify playlists from YouTube Music'
27 changes: 22 additions & 5 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
from yt_music import setup_YTMusic, sync_playlists

"""Main file for the application."""
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__":
ytmusic = setup_YTMusic()
spotify = setup_spotipy()
ytmusic = setup_ytmusic()

operation: Operation = main_menu()

match operation:

case Operation.SYNC_YOUTUBE_PLAYLISTS_WITH_SPOTIFY:
sync_spotify_to_ytmusic(spotify, ytmusic)

playlists_dir = 'resources/playlists/'
case Operation.SYNC_SPOTIFY_PLAYLISTS_WITH_YOUTUBE:
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}")

sync_playlists(ytmusic, playlists_dir)
case _:
print("Invalid operation")
128 changes: 128 additions & 0 deletions src/playlist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""This module contains the Playlist class and its methods."""

from __future__ import annotations
from dataclasses import dataclass
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
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]:
"""
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 []

if 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(get_song_from_spotify_json(song["track"]))

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
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=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
self.description: str = description
self.songs: list[Song] = songs

def __str__(self):
return "Playlist{name=" + self.name + \
", id=" + self.id + \
", image=" + self.image + \
", description=" + self.description + "}"
Loading