diff --git a/spotify_integration/.gitignore b/spotify_integration/.gitignore new file mode 100644 index 0000000..9ff2fcf --- /dev/null +++ b/spotify_integration/.gitignore @@ -0,0 +1,187 @@ +# Don't commit env file that has API keys +.env +*.json +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# 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/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# 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 diff --git a/spotify_integration/Cargo.toml b/spotify_integration/Cargo.toml new file mode 100644 index 0000000..a9da154 --- /dev/null +++ b/spotify_integration/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "to_spotify" +version = "0.1.0" +edition = "2021" + +[dependencies] +# This must be on a single line for some reason +rspotify = { version = "0.13.3",default-features = false,features = ["env-file", "client-ureq", "ureq-rustls-tls"]} +dotenv = "0.15.0" \ No newline at end of file diff --git a/spotify_integration/README.md b/spotify_integration/README.md new file mode 100644 index 0000000..048bfdc --- /dev/null +++ b/spotify_integration/README.md @@ -0,0 +1,45 @@ +# About + +Provides API integration with [Spotify](https://en.wikipedia.org/wiki/Spotify) + +After running the iTunesDB parser application on an iTunesDB file, you end up with a music CSV file that contains info about your songs. This tool lets you then import that CSV file into a Spotify playlist. + +If you want to use this tool with your _own_ CSV files, see `test.csv` for an example of the format. + +## Code usage + +Install dependencies + +```bash +$ pip install requirements.txt +``` + +``` +usage: spotify_integration.py [-h] -f CSV_FILE [-t TRACK_COLUMN] -a API_CREDENTIALS_FILE + +Read CSV files containing songs and search for them in Spotify + +options: + -h, --help show this help message and exit + -f CSV_FILE, --csv-file CSV_FILE + Path to the CSV file containing the songs + -t TRACK_COLUMN, --track-column TRACK_COLUMN + Name of the column containing the track names + -a API_CREDENTIALS_FILE, --api-credentials-file API_CREDENTIALS_FILE + Path to JSON file containing API credentials, see + spotify_api_credentials.json for the format +``` + +# API instructions + +See `spotify_api_credentials.json` for example of format + +Both client_id and client_secret are 32-character alphanumeric values + +See: https://developer.spotify.com/documentation/web-api/concepts/apps +for instructions on how to generate! + +After running you will be sent to a authorization prompt in your browser and then redirected to a URL + +The application will ask you to paste that URL to retrieve the code + diff --git a/spotify_integration/requirements.txt b/spotify_integration/requirements.txt new file mode 100644 index 0000000..b8e41a7 --- /dev/null +++ b/spotify_integration/requirements.txt @@ -0,0 +1,7 @@ +certifi==2024.8.30 +charset-normalizer==3.4.0 +idna==3.10 +redis==5.2.0 +requests==2.32.3 +spotipy==2.24.0 +urllib3==2.2.3 diff --git a/spotify_integration/spotify_api_credentials.json b/spotify_integration/spotify_api_credentials.json new file mode 100644 index 0000000..c189604 --- /dev/null +++ b/spotify_integration/spotify_api_credentials.json @@ -0,0 +1,5 @@ +{ + "client_id": "HdtSF4bZRaBBSTISX1oJq0ewYkspqsdB", + "client_secret": "ehZjXinzzmzxPjQ75E7YkvF57uYJstBU", + "redirect_uri": "your_redirect_uri" +} \ No newline at end of file diff --git a/spotify_integration/spotify_integration.py b/spotify_integration/spotify_integration.py new file mode 100644 index 0000000..4063419 --- /dev/null +++ b/spotify_integration/spotify_integration.py @@ -0,0 +1,97 @@ +import csv +import spotipy +import argparse +import socket +import json +import datetime + +from spotipy.oauth2 import SpotifyClientCredentials +from spotipy.oauth2 import SpotifyOAuth + +UNITED_STATES_ISO_3166_1_CODE = "US" +DEFAULT_QUERY_LIMIT = 3 +DEFAULT_SPOTIFY_API_SCOPE = "playlist-modify-private" + +def get_track_ids_from_csv(csv_file, spotify_api_obj) -> list: + + spotify_tracks = list() + num_songs_found = 0 + + with open(csv_file, mode='r') as csv_file: + csv_reader = csv.DictReader(csv_file) + + sp_obj = spotipy.Spotify(auth_manager=SpotifyClientCredentials( + client_id=spotify_api_obj["client_id"], client_secret=spotify_api_obj["client_secret"])) + + for csv_row in csv_reader: + song_title = csv_row[argparse_args.track_column] + song_artist = csv_row["Artist"] + + if not song_title or not song_artist: + print(f"Skipping row... missing song title or artist") + continue + + search_results = sp_obj.search(q=f"track:{song_title} artist:{ + song_artist}", type="track", limit=DEFAULT_QUERY_LIMIT, market=UNITED_STATES_ISO_3166_1_CODE) + + if search_results["tracks"]["total"] == 0: + print(f"Song '{song_title}' by '{ + song_artist}' not found on Spotify!") + continue + + else: + num_songs_found += 1 + track_id = search_results["tracks"]["items"][0]["id"] + spotify_tracks.append( + dict(song_title=song_title, song_artist=song_artist, track_id=track_id)) + + print(f"[DEBUG] Found {num_songs_found} songs on Spotify") + return spotify_tracks + + +def create_playlist_from_tracks(spotify_tracks: list, playlist_name: str, playlist_description: str, spotify_api_obj) -> str: + + sp_obj = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id=spotify_api_obj["client_id"], client_secret=spotify_api_obj["client_secret"], redirect_uri=spotify_api_obj["redirect_uri"], scope=DEFAULT_SPOTIFY_API_SCOPE)) + + user_id = sp_obj.current_user()["id"] + playlist_id = sp_obj.user_playlist_create( + user_id, playlist_name, public=False, description=playlist_description)["id"] + + track_ids = [track["track_id"] for track in spotify_tracks] + sp_obj.playlist_add_items(playlist_id, track_ids) + + return playlist_id + + +if __name__ == '__main__': + + argparse_parser = argparse.ArgumentParser( + description="Read CSV files containing songs and search for them in Spotify") + argparse_parser.add_argument( + "-f", "--csv-file", help="Path to the CSV file containing the songs", required=True) + argparse_parser.add_argument( + "-t", "--track-column", help="Name of the column containing the track names", required=False, default="Song Title") + argparse_parser.add_argument("-a", "--api-credentials-file", + help="Path to JSON file containing API credentials, see spotify_api_credentials.json for the format", required=True) + argparse_args = argparse_parser.parse_args() + + if not argparse_args.csv_file: + raise FileNotFoundError( + f"Error: CSV file '{argparse_args.csv_file}' not found") + + if not argparse_args.api_credentials_file: + raise FileNotFoundError( + f"Error: API credentials file '{argparse_args.api_credentials_file}' not found! Go to https://developer.spotify.com/documentation/web-api to create one") + + api_obj = json.load(open(argparse_args.api_credentials_file)) + + spotify_tracks_and_track_ids = get_track_ids_from_csv( + argparse_args.csv_file, api_obj) + + # Cannot have newline in playlist description + playlist_description = f"Playlist created on {datetime.datetime.now().isoformat()} Created by {socket.gethostname()}" + + new_playlist_id = create_playlist_from_tracks( + spotify_tracks_and_track_ids, "Test Playlist", playlist_description, api_obj) + + print(f"Created new playlist with ID {new_playlist_id}") diff --git a/spotify_integration/src/main.rs b/spotify_integration/src/main.rs new file mode 100644 index 0000000..9a68a67 --- /dev/null +++ b/spotify_integration/src/main.rs @@ -0,0 +1,19 @@ +use rspotify::clients::BaseClient; +use rspotify::model::SearchType; + +fn main() { + println!("Loading credentials from .env file .."); + let creds_from_env_file = rspotify::Credentials::from_env().unwrap(); + println!("Credentials loaded! Creating client obj.. "); + let spotify_cli_obj = rspotify::ClientCredsSpotify::new(creds_from_env_file); + + // Obtaining the access token - must be done before any query is made + spotify_cli_obj.request_token().unwrap(); + + let album_query = "album:arrival artist:abba"; + let result = spotify_cli_obj.search(album_query, SearchType::Album, None, None, Some(10), None); + match result { + Ok(album) => println!("Searched album: {album:?}"), + Err(err) => println!("Search error! {err:?}"), + } +}