Skip to content

Commit

Permalink
Add Spotify -> Apple Music integration (#3)
Browse files Browse the repository at this point in the history
* Add url to playlist description.

* made some progress.

* Fix bug.

* Move fetch_url & grouper into utils.

* add stuff.

* update

* Refactor stuff.

* Exciting improvement.

* Move __init__ into AppleMusicParser.

* Fix typo.

* Add jwt.

* Add function to generate apple music auth token.

* Ignore private key.

* Add comment.

* Refactor code.

* Upgrade redis and celery.

* Bump requests to 2.2.0

* Refactor.

* Remove playlist.html

* Properly handle paginated results.

* Clean up.

* Don't use trailing commas.

* Cleaner code.

* Update spotify and apple music logos.

* Bump psycopg2 to 2.8.4

* Add apple music credentials.

* Add sweetalert 2.

* Add musickit.js

* Add context_processors.

* Bump to python 3.7.5.

* Add stuff.

* Add template context processor.

* Update.

* Remove styling for convertor button.

* Update tasks.

* It's fucking working 🤯

* Display only urls as links.

* dependencies: Add cryptography.

* Update readme.

* Update settings.
  • Loading branch information
akornor committed Nov 16, 2019
1 parent 5c6b0da commit e32ac75
Show file tree
Hide file tree
Showing 42 changed files with 3,818 additions and 71 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Expand Up @@ -55,6 +55,7 @@ coverage.xml
*.log
local_settings.py
db.sqlite3
staticfiles/*

# Flask stuff:
instance/
Expand Down Expand Up @@ -106,4 +107,5 @@ venv.bak/
#spotify access token
token.json

# heroku
#apple music private key
private.pem
5 changes: 4 additions & 1 deletion README.md
Expand Up @@ -21,4 +21,7 @@ Run `nf start --port 8000` to start app.
To use app, navigate to `localhost:8000`. You'll be required to login with your Spotify credentials on first attempt.

## TODO
- [ ] Add feature to convert apple music playlist to spotify
- [X] Add feature to convert apple music playlist to spotify

## Thanks and Acknowledgments
- Thanks to Maame, Mayor, Samuel, Elikem, Dejong, Ike and Paul for helping me put this together. Not sure we'll have playlistor without you guys.
12 changes: 12 additions & 0 deletions audible/settings.py
Expand Up @@ -28,6 +28,13 @@
def get_secret(name, default=None):
return os.environ.get(name, default)

def get_from_file_if_exists(path: str) -> str:
if os.path.exists(path):
with open(path, "r") as f:
return f.read()
else:
return ''


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
Expand Down Expand Up @@ -74,6 +81,7 @@ def get_secret(name, default=None):
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'main.context_processors.default_context'
],
},
},
Expand Down Expand Up @@ -137,4 +145,8 @@ def get_secret(name, default=None):
REDIRECT_URI = get_secret('REDIRECT_URI')
CLIENT_ID = get_secret('CLIENT_ID')
CLIENT_SECRET = get_secret('CLIENT_SECRET')
APPLE_KEY_ID = get_secret('APPLE_KEY_ID')
APPLE_TEAM_ID = get_secret('APPLE_TEAM_ID')
APPLE_PRIVATE_KEY = get_from_file_if_exists(os.path.join(BASE_DIR, 'private.pem'))

django_heroku.settings(locals())
6 changes: 6 additions & 0 deletions main/context_processors.py
@@ -0,0 +1,6 @@
from .utils import generate_auth_token


def default_context(request):
token = generate_auth_token()
return {"APPLE_DEVELOPER_TOKEN": token}
58 changes: 51 additions & 7 deletions main/parsers.py
@@ -1,20 +1,22 @@
from collections import namedtuple
import re
from .utils import get_spotify_client, fetch_url

Track = namedtuple("Track", ["title", "artist", "featuring"])


class BaseParser:
def __init__(self, html_source: str) -> None:
from bs4 import BeautifulSoup

self._soup = BeautifulSoup(html_source, "html.parser")

def extract_data(self):
raise NotImplementedError()


class AppleMusicParser(BaseParser):
def __init__(self, playlist_url: str) -> None:
html = fetch_url(playlist_url)
from bs4 import BeautifulSoup

self._soup = BeautifulSoup(html, "html.parser")

def extract_data(self):
return {
"playlist_title": self._get_playlist_title(),
Expand All @@ -23,8 +25,7 @@ def extract_data(self):
}

def _get_playlist_title(self):
title = self._soup.find(class_="product-header__title").get_text().strip()
return title
return self._soup.find(class_="product-header__title").get_text().strip()

def _get_playlist_tracks(self):
soup = self._soup
Expand Down Expand Up @@ -57,3 +58,46 @@ def _get_playlist_creator(self):
.get_text()
.strip()
)


class SpotifyParser(BaseParser):
def __init__(self, playlist_url):
PLAYLIST_RE = r"https://open.spotify.com/playlist/(.+)"
mo = re.match(PLAYLIST_RE, playlist_url)
if not mo:
raise ValueError(
"Expected playlist url in the form: https://open.spotify.com/playlist/68QbTIMkw3Gl6Uv4PJaeTQ"
)
playlist_id = mo.group(1)
self.sp = get_spotify_client()
self.playlist = self.sp.playlist(playlist_id=playlist_id)

def extract_data(self):
return {
"playlist_title": self._get_playlist_title(),
"tracks": self._get_playlist_tracks(),
"playlist_creator": self._get_playlist_creator(),
}

def _get_playlist_title(self):
return self.playlist["name"]

def _get_playlist_tracks(self):
tracks = []
all_track_results = [] + self.playlist["tracks"]["items"]
next = self.playlist["tracks"]["next"]
results = self.playlist["tracks"]
while next:
results = self.sp.next(results)
if results is None:
break
all_track_results += results["items"]
next = results.get("next")
for track in all_track_results:
title = track["track"]["name"]
artist = track["track"]["artists"][0]["name"]
tracks.append(Track(title=title, artist=artist, featuring=""))
return tracks

def _get_playlist_creator(self):
return "Tyler, the creator."
2 changes: 1 addition & 1 deletion main/static/css/style.css
Expand Up @@ -546,4 +546,4 @@ a, a:link, a:hover, a:visited, a:active {color: var(--color-blue);}
#progress-bar-message a {
text-decoration: none;
font-size: 14px;
}
}
41 changes: 35 additions & 6 deletions main/static/js/main.js
Expand Up @@ -13,19 +13,46 @@ const resetButton = () => {
button.disabled = false;
};

function getDestinationPlatform(url) {
const PLAYLIST_URL_REGEX = /open\.spotify\.com\/playlist\/.+/g;
if (PLAYLIST_URL_REGEX.test(url)) {
return "apple-music";
} else {
return "spotify";
}
}

button.onclick = async function(event) {
event.preventDefault();
const playlist = $("#playlist").value;
const playlist = $("#playlist").value.trim();
if (playlist === "") {
return;
}
if (!is_valid_url(playlist.trim())) {
swal(
if (!is_valid_url(playlist)) {
Swal.fire(
"Invalid URL",
"Enter valid url e.g https://itunes.apple.com/us/playlist/ep-3-paak-house-radio-playlist/pl.be45d23328f642cc91cf7086c7126daf"
);
return;
}
// See https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time. Pretty interesting bug 😂
const PLAYLIST_URL_REGEX = /open\.spotify\.com\/playlist\/.+/g;
if (
PLAYLIST_URL_REGEX.test(playlist) &&
!MusicKit.getInstance().isAuthorized
) {
const result = await Swal.fire({
title: "🚨Sign In🚨",
html:
"We noticed you're trying to convert Spotify ➡️ Apple Music. Kindly sign in with your Apple Music account to continue",
showCloseButton: true,
confirmButtonText: "SIGN IN."
});
if (result.value) {
MusicKit.getInstance().authorize();
}
return;
}
// clear progress bar
resetProgressBar();
button.innerHTML = "<i class='fa fa-spinner fa-spin '></i>";
Expand All @@ -34,14 +61,16 @@ button.onclick = async function(event) {
const response = await fetch("/playlist", {
method: "POST",
body: JSON.stringify({
playlist
playlist,
platform: getDestinationPlatform(playlist)
}),
headers: {
"Content-Type": "application/json"
"Content-Type": "application/json",
"Music-User-Token": `${MusicKit.getInstance().musicUserToken}`
}
});
if (!response.ok) {
throw Error(response.statusText);
throw new Error(response.statusText);
}
let { task_id } = await response.json();
const progressUrl = `/celery-progress/${task_id}/`;
Expand Down
6 changes: 3 additions & 3 deletions main/static/js/vendor/celery_progress.js
Expand Up @@ -9,9 +9,9 @@ const CeleryProgressBar = (function() {
data
) {
progressBarElement.style.backgroundColor = "#76ce60";
progressBarMessageElement.innerHTML = `<a target="_blank" href="${
data.result
}">${data.result}</a>`;
progressBarMessageElement.innerHTML = is_valid_url(data.result)
? `<a target="_blank" href="${data.result}">${data.result}</a>`
: data.result;
resetButton();
}

Expand Down
1 change: 1 addition & 0 deletions main/static/js/vendor/musickit.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion main/static/js/vendor/sweetalert.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion main/static/svg/apple-music.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions main/static/svg/button.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
77 changes: 60 additions & 17 deletions main/tasks.py
@@ -1,29 +1,24 @@
import json
from audible.celery import app
from celery import shared_task
from spotipy import Spotify, SpotifyException
from .parsers import AppleMusicParser
from .utils import fetch_url, grouper, redis_client
from main import oauth
from celery_progress.backend import ProgressRecorder


def get_access_token():
return oauth.get_cached_token()["access_token"]


def get_spotify_client(token):
return Spotify(auth=token)
from spotipy import SpotifyException
from .parsers import AppleMusicParser, SpotifyParser
from .utils import (
grouper,
get_redis_client,
get_spotify_client,
requests_retry_session,
generate_auth_token,
)


@shared_task(bind=True)
def generate_playlist(self, url):
def generate_spotify_playlist(self, url):
progress_recorder = ProgressRecorder(self)
token = get_access_token()
sp = get_spotify_client(token)
sp = get_spotify_client()
uid = sp.current_user()["id"]
html = fetch_url(url)
data = AppleMusicParser(html).extract_data()
data = AppleMusicParser(url).extract_data()
playlist_title = data["playlist_title"]
tracks = data["tracks"]
creator = data["playlist_creator"]
Expand Down Expand Up @@ -59,6 +54,7 @@ def generate_playlist(self, url):
raise e
playlist_url = playlist["external_urls"]["spotify"]
# Store playlist info
redis_client = get_redis_client()
redis_client.lpush(
"playlists",
json.dumps(
Expand All @@ -70,3 +66,50 @@ def generate_playlist(self, url):
),
)
return playlist_url


@shared_task(bind=True)
def generate_applemusic_playlist(self, url, token):
progress_recorder = ProgressRecorder(self)
data = SpotifyParser(url).extract_data()
tracks = data["tracks"]
playlist_title = data["playlist_title"]
playlist_data = []
n = len(tracks)
auth_token = generate_auth_token()
headers = {
"Authorization": f"Bearer {auth_token}",
"Music-User-Token": token
}
_session = requests_retry_session()
for i, track in enumerate(tracks):
try:
params = {"term": f"{track.title} {track.artist}", "limit": 1}
response = _session.get(
"https://api.music.apple.com/v1/catalog/us/search",
params=params,
headers=headers,
)
response.raise_for_status()
results = response.json()
song = results["results"]["songs"]["data"][0]
playlist_data.append({"id": song["id"], "type": song["type"]})
except:
continue
finally:
progress_recorder.set_progress(i + 1, n)
payload = {
"attributes": {
"name": playlist_title,
"description": f"Originally created on Spotify[{url}]",
},
"relationships": {"tracks": {"data": playlist_data}},
}
# create playlist here
response = _session.post(
"https://api.music.apple.com/v1/me/library/playlists",
data=json.dumps(payload),
headers=headers,
)
response.raise_for_status()
return "Check your recently created playlists on Apple Music."
17 changes: 14 additions & 3 deletions main/templates/index.html
Expand Up @@ -36,9 +36,7 @@ <h3 class="home__description"><span id="counter">0</span> playlists converted.</
</div>
<div style="word-wrap: break-word;" id="progress-bar-message" class="text-center"></div>
<div style="text-align: center;">
<button id="btn" class="button" data-loading-text="<i class='fa fa-spinner fa-spin '></i>" style="
width: 80px;
">Convert</button>
<button id="btn" class="button" data-loading-text="<i class='fa fa-spinner fa-spin '></i>" style="width: 80px;">Convert</button>
</div>
</div>
{% if playlists %}
Expand All @@ -63,6 +61,19 @@ <h2 class="home__subtitle">Recent playlists</h2>
<a href="https://github.com/akornor/playlistor" target="_blank">Github</a>
</p> -->
</footer>
<script type="text/javascript">
document.addEventListener('musickitloaded', function() {
// MusicKit global is now defined
MusicKit.configure({
developerToken: "{{ APPLE_DEVELOPER_TOKEN }}",
app: {
name: 'Playlistor',
build: '1.0'
}
});
});
</script>
<script src="{% static 'js/vendor/musickit.js' %}"></script>
<script src="{% static 'js/vendor/sweetalert.min.js' %}"></script>
<script src="{% static 'js/vendor/celery_progress.js' %}"></script>
<script src="{% static 'js/vendor/countUp.min.js' %}"></script>
Expand Down

0 comments on commit e32ac75

Please sign in to comment.