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

Added exporting playlists to Apple functionality #2968

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
77 changes: 62 additions & 15 deletions frontend/js/src/playlists/components/PlaylistMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/anchor-is-valid */
/* eslint-disable react/jsx-no-comment-textnodes */
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { faSpotify } from "@fortawesome/free-brands-svg-icons";
import { faSpotify, faItunesNote } from "@fortawesome/free-brands-svg-icons";
import {
faCopy,
faFileExport,
Expand Down Expand Up @@ -36,7 +36,9 @@ function PlaylistMenu({
onPlaylistCopied,
disallowEmptyPlaylistExport,
}: PlaylistMenuProps) {
const { APIService, currentUser, spotifyAuth } = useContext(GlobalAppContext);
const { APIService, currentUser, spotifyAuth, appleAuth } = useContext(
GlobalAppContext
);
const { auth_token } = currentUser;
const playlistID = getPlaylistId(playlist);
const playlistTitle = playlist.title;
Expand Down Expand Up @@ -159,6 +161,39 @@ function PlaylistMenu({
{ toastId: "export-playlist" }
);
};
const exportToAppleMusic = async () => {
if (!auth_token) {
alertMustBeLoggedIn();
return;
}
let result;
if (playlistID) {
result = await APIService.exportPlaylistToAppleMusic(
auth_token,
playlistID
);
} else {
result = await APIService.exportJSPFPlaylistToAppleMusic(
auth_token,
playlist
);
}
const { external_url } = result;
toast.success(
<ToastMsg
title="Playlist exported to Apple Music"
message={
<>
Successfully exported playlist:{" "}
<a href={external_url} target="_blank" rel="noopener noreferrer">
{playlistTitle}
</a>
</>
}
/>,
{ toastId: "export-playlist" }
);
};
const handlePlaylistExport = async (handler: () => Promise<void>) => {
if (!playlist || (disallowEmptyPlaylistExport && !playlist.track.length)) {
toast.warn(
Expand All @@ -179,6 +214,7 @@ function PlaylistMenu({
const showSpotifyExportButton = spotifyAuth?.permission?.includes(
"playlist-modify-public"
);
const showAppleMusicExportButton = appleAuth;
return (
<ul
className="dropdown-menu dropdown-menu-right"
Expand Down Expand Up @@ -233,20 +269,31 @@ function PlaylistMenu({
</li>
</>
)}
<li role="separator" className="divider" />
{showSpotifyExportButton && (
<>
<li role="separator" className="divider" />
<li>
<a
id="exportPlaylistToSpotify"
role="button"
href="#"
onClick={() => handlePlaylistExport(exportToSpotify)}
>
<FontAwesomeIcon icon={faSpotify as IconProp} /> Export to Spotify
</a>
</li>
</>
<li>
<a
id="exportPlaylistToSpotify"
role="button"
href="#"
onClick={() => handlePlaylistExport(exportToSpotify)}
>
<FontAwesomeIcon icon={faSpotify as IconProp} /> Export to Spotify
</a>
</li>
)}
{showAppleMusicExportButton && (
<li>
<a
id="exportPlaylistToAppleMusic"
role="button"
href="#"
onClick={() => handlePlaylistExport(exportToAppleMusic)}
>
<FontAwesomeIcon icon={faItunesNote as IconProp} /> Export to Apple
Music
</a>
</li>
)}
<li role="separator" className="divider" />
<li>
Expand Down
33 changes: 33 additions & 0 deletions frontend/js/src/utils/APIService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,39 @@ export default class APIService {
return response.json();
};

exportPlaylistToAppleMusic = async (
userToken: string,
playlist_mbid: string
): Promise<any> => {
const url = `${this.APIBaseURI}/playlist/${playlist_mbid}/export/apple_music`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Token ${userToken}`,
"Content-Type": "application/json;charset=UTF-8",
},
});
await this.checkStatus(response);
return response.json();
};

exportJSPFPlaylistToAppleMusic = async (
userToken: string,
playlist: JSPFPlaylist
): Promise<any> => {
const url = `${this.APIBaseURI}/playlist/export-jspf/apple_music`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Token ${userToken}`,
"Content-Type": "application/json;charset=UTF-8",
},
body: JSON.stringify(playlist),
});
await this.checkStatus(response);
return response.json();
};

exportJSPFPlaylistToSpotify = async (
userToken: string,
playlist: JSPFPlaylist
Expand Down
224 changes: 126 additions & 98 deletions listenbrainz/tests/integration/test_playlist_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1117,103 +1117,131 @@ def test_playlist_invalid_user(self):
)
self.assert403(response)

@requests_mock.Mocker()
@mock.patch("listenbrainz.webserver.views.playlist_api.export_to_spotify")
def test_playlist_export(self, mock_requests, mock_troi_bot):
""" Test various error cases related to exporting a playlist to spotify """
mock_requests.post(OAUTH_TOKEN_URL, status_code=200, json={
'access_token': 'tokentoken',
'expires_in': 3600,
'scope': '',
})

playlist = {
"playlist": {
"title": "my stupid playlist",
"extension": {
PLAYLIST_EXTENSION_URI: {
"public": True
}
},
}
@requests_mock.Mocker()
@mock.patch("listenbrainz.webserver.views.playlist_api.export_to_spotify")
@mock.patch("listenbrainz.webserver.views.playlist_api.export_to_apple_music")
def test_playlist_export(self, mock_requests, mock_troi_bot):
""" Test various error cases related to exporting a playlist to spotify and apple music """
mock_requests.post(OAUTH_TOKEN_URL, status_code=200, json={
'access_token': 'tokentoken',
'expires_in': 3600,
'scope': '',
})

playlist = {
"playlist": {
"title": "my updated playlist",
"extension": {
PLAYLIST_EXTENSION_URI: {
"public": True
}
},
}
}

response = self.client.post(
self.custom_url_for("playlist_api_v1.create_playlist"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert200(response)
playlist_mbid = response.json["playlist_mbid"]

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="lastfm"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert400(response)
self.assertEqual(response.json["error"], "Service lastfm is not supported. We currently only support 'spotify'.")

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="spotify"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert400(response)
self.assertEqual(response.json["error"], "Service spotify is not linked. Please link your spotify account first.")

db_oauth.save_token(
self.db_conn,
user_id=self.user['id'],
service=ExternalServiceType.SPOTIFY,
access_token='token',
refresh_token='refresh_token',
token_expires_ts=int(time.time()),
record_listens=True,
scopes=['user-read-recently-played']
)

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="spotify"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert400(response)
self.assertEqual(
response.json["error"],
"Missing scopes playlist-modify-public and playlist-modify-private to export playlists."
" Please relink your spotify account from ListenBrainz settings with appropriate scopes"
" to use this feature."
)

db_oauth.delete_token(self.db_conn, self.user['id'], ExternalServiceType.SPOTIFY, True)

db_oauth.save_token(
self.db_conn,
user_id=self.user['id'],
service=ExternalServiceType.SPOTIFY,
access_token='token',
refresh_token='refresh_token',
token_expires_ts=int(time.time()),
record_listens=True,
scopes=[
'streaming',
'user-read-email',
'user-read-private',
'playlist-modify-public',
'playlist-modify-private',
'user-read-currently-playing',
'user-read-recently-played'
]
)
mock_troi_bot.assert_not_called()

mock_troi_bot.return_value = "foobar"

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="spotify"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert200(response)
self.assertEqual(response.json, {"external_url": "foobar"})
response = self.client.post(
self.custom_url_for("playlist_api_v1.create_playlist"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert200(response)
playlist_mbid = response.json["playlist_mbid"]

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="lastfm"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert400(response)
self.assertEqual(response.json["error"], "Service lastfm is not supported. We currently only support 'spotify' and 'apple_music'.")

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="spotify"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert400(response)
self.assertEqual(response.json["error"], "Service spotify is not linked. Please link your spotify account first.")

db_oauth.save_token(
self.db_conn,
user_id=self.user['id'],
service=ExternalServiceType.SPOTIFY,
access_token='token',
refresh_token='refresh_token',
token_expires_ts=int(time.time()),
record_listens=True,
scopes=['user-read-recently-played']
)

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="spotify"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert400(response)
self.assertEqual(
response.json["error"],
"Missing scopes playlist-modify-public and playlist-modify-private to export playlists."
" Please relink your spotify account from ListenBrainz settings with appropriate scopes"
" to use this feature."
)

db_oauth.delete_token(self.db_conn, self.user['id'], ExternalServiceType.SPOTIFY, True)

db_oauth.save_token(
self.db_conn,
user_id=self.user['id'],
service=ExternalServiceType.SPOTIFY,
access_token='token',
refresh_token='refresh_token',
token_expires_ts=int(time.time()),
record_listens=True,
scopes=[
'streaming',
'user-read-email',
'user-read-private',
'playlist-modify-public',
'playlist-modify-private',
'user-read-currently-playing',
'user-read-recently-played'
]
)
mock_troi_bot.assert_not_called()
mock_troi_bot.return_value = "spotify_url"

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="spotify"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert200(response)
self.assertEqual(response.json, {"external_url": "spotify_url"})

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="apple_music"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert400(response)
self.assertEqual(response.json["error"], "Service apple_music is not linked. Please link your apple_music account first.")

db_oauth.save_token(
self.db_conn,
user_id=self.user['id'],
service=ExternalServiceType.APPLE_MUSIC,
access_token='token',
refresh_token='refresh_token',
token_expires_ts=int(time.time()),
record_listens=True
)
mock_troi_bot.assert_not_called()
mock_troi_bot.return_value = "apple_music_url"

response = self.client.post(
self.custom_url_for("playlist_api_v1.export_playlist", playlist_mbid=playlist_mbid, service="apple_music"),
json=playlist,
headers={"Authorization": "Token {}".format(self.user["auth_token"])}
)
self.assert200(response)
self.assertEqual(response.json, {"external_url": "apple_music_url"})
Loading
Loading