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

SoundCloud Playlist Export & Import functionalities #2965

Open
wants to merge 11 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
75 changes: 69 additions & 6 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, faSoundcloud } 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, soundcloudAuth } = 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 exportToSoundcloud = async () => {
if (!auth_token) {
alertMustBeLoggedIn();
return;
}
let result;
if (playlistID) {
result = await APIService.exportPlaylistToSoundCloud(
auth_token,
playlistID
);
} else {
result = await APIService.exportJSPFPlaylistToSoundCloud(
auth_token,
playlist
);
}
const { external_url } = result;
toast.success(
<ToastMsg
title="Playlist exported to Soundcloud"
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 showSoundCloudExportButton = soundcloudAuth;
return (
<ul
className="dropdown-menu dropdown-menu-right"
Expand Down Expand Up @@ -233,21 +269,48 @@ function PlaylistMenu({
</li>
</>
)}
<li role="separator" className="divider" />
{showSpotifyExportButton && (
<li>
<a
id="exportPlaylistToSpotify"
role="button"
href="#"
onClick={() => handlePlaylistExport(exportToSpotify)}
>
<FontAwesomeIcon icon={faSpotify as IconProp} /> Export to Spotify
</a>
</li>
)}
{showSoundCloudExportButton && (
<li>
<a
id="exportPlaylistToSoundCloud"
role="button"
href="#"
onClick={() => handlePlaylistExport(exportToSoundcloud)}
>
<FontAwesomeIcon icon={faSoundcloud as IconProp} /> Export to
SoundCloud
</a>
</li>
)}
{/* {showSoundCloudExportButton && (
<>
<li role="separator" className="divider" />
<li>
<a
id="exportPlaylistToSpotify"
id="exportPlaylistToSoundCloud"
role="button"
href="#"
onClick={() => handlePlaylistExport(exportToSpotify)}
onClick={() => handlePlaylistExport(exportToSoundcloud)}
>
<FontAwesomeIcon icon={faSpotify as IconProp} /> Export to Spotify
<FontAwesomeIcon icon={faSoundcloud as IconProp} /> Export to
SoundCloud
</a>
</li>
</>
)}
)} */}
<li role="separator" className="divider" />
<li>
<a
Expand Down
30 changes: 29 additions & 1 deletion frontend/js/src/user/playlists/Playlists.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
faFileImport,
faMusic,
} from "@fortawesome/free-solid-svg-icons";
import { faSpotify, faItunesNote } from "@fortawesome/free-brands-svg-icons";
import {
faSpotify,
faItunesNote,
faSoundcloud,
} from "@fortawesome/free-brands-svg-icons";
import * as React from "react";

import NiceModal from "@ebay/nice-modal-react";
Expand All @@ -25,6 +29,7 @@ import CreateOrEditPlaylistModal from "../../playlists/components/CreateOrEditPl
import ImportPlaylistModal from "./components/ImportJSPFPlaylistModal";
import ImportSpotifyPlaylistModal from "./components/ImportSpotifyPlaylistModal";
import ImportAppleMusicPlaylistModal from "./components/ImportAppleMusicPlaylistModal";
import ImportSoundCloudPlaylistModal from "./components/ImportSoundCloudPlaylistModal";
import PlaylistsList from "./components/PlaylistsList";
import { getPlaylistId, PlaylistType } from "../../playlists/utils";

Expand Down Expand Up @@ -222,6 +227,29 @@ export default class UserPlaylists extends React.Component<
&nbsp;Apple Music
</button>
</li>
<li>
<button
type="button"
onClick={() => {
NiceModal.show<JSPFPlaylist | JSPFPlaylist[], any>(
ImportSoundCloudPlaylistModal
).then((playlist) => {
if (Array.isArray(playlist)) {
playlist.forEach((p: JSPFPlaylist) => {
this.onPlaylistCreated(p);
});
} else {
this.onPlaylistCreated(playlist);
}
});
}}
data-toggle="modal"
data-target="#ImportMusicServicePlaylistModal"
>
<FontAwesomeIcon icon={faSoundcloud} />
&nbsp;SoundCloud
</button>
</li>
<li>
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import React from "react";
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { toast } from "react-toastify";
import { Link } from "react-router-dom";
import GlobalAppContext from "../../../utils/GlobalAppContext";
import { ToastMsg } from "../../../notifications/Notifications";
import Loader from "../../../components/Loader";

type ImportPLaylistModalProps = {
listenlist: JSPFTrack[];
};

export default NiceModal.create((props: ImportPLaylistModalProps) => {
const modal = useModal();

const closeModal = React.useCallback(() => {
modal.hide();
document?.body?.classList?.remove("modal-open");
setTimeout(modal.remove, 200);
}, [modal]);

const { APIService, currentUser } = React.useContext(GlobalAppContext);
const [playlists, setPlaylists] = React.useState<SoundCloudPlaylistObject[]>(
[]
);
const [newPlaylists, setNewPlaylists] = React.useState<JSPFPlaylist[]>([]);
const [playlistLoading, setPlaylistLoading] = React.useState<string | null>(
null
);

React.useEffect(() => {
async function getUserSoundcloudPlaylists() {
try {
const response = await APIService.importPlaylistFromSoundCloud(
currentUser?.auth_token
);

if (!response) {
return;
}

setPlaylists(response);
} catch (error) {
toast.error(
<ToastMsg
title="Error loading playlists"
message={
error?.message === "Unauthorized" ? (
<>
Session has expired. Please reconnect to{" "}
<Link to="/settings/music-services/details/">Soundcloud</Link>.
</>
) : error?.message === "Not Found" ? (
"The requested resource was not found."
) : (
error?.message ?? error
)
}
/>,
{ toastId: "load-playlists-error" }
);
}
}

if (currentUser?.auth_token) {
getUserSoundcloudPlaylists();
}
}, [APIService, currentUser]);

const resolveAndClose = () => {
modal.resolve(newPlaylists);
closeModal();
};

const alertMustBeLoggedIn = () => {
toast.error(
<ToastMsg
title="Error"
message="You must be logged in for this operation"
/>,
{ toastId: "auth-error" }
);
};

const importTracksToPlaylist = async (
playlistID: string,
playlistName: string
) => {
if (!currentUser?.auth_token) {
alertMustBeLoggedIn();
return;
}
setPlaylistLoading(playlistName);
try {
const newPlaylist: JSPFPlaylist = await APIService.importSoundCloudPlaylistTracks(
currentUser?.auth_token,
playlistID
);
toast.success(
<ToastMsg
title="Successfully imported playlist from Soundcloud"
message={
<>
Imported
<Link to={newPlaylist.identifier}> {playlistName}</Link>
</>
}
/>,
{ toastId: "create-playlist-success" }
);
setNewPlaylists([...newPlaylists, newPlaylist]);
} catch (error) {
toast.error(
<ToastMsg
title="Something went wrong"
message={<>We could not save your playlist: {error.toString()}</>}
/>,
{ toastId: "save-playlist-error" }
);
}
setPlaylistLoading(null);
};

return (
<div
className={`modal fade ${modal.visible ? "in" : ""}`}
id="ImportMusicServicePlaylistModal"
tabIndex={-1}
role="dialog"
aria-labelledby="ImportMusicServicePlaylistLabel"
data-backdrop="static"
>
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<button
type="button"
className="close"
data-dismiss="modal"
aria-label="Close"
onClick={resolveAndClose}
>
<span aria-hidden="true">&times;</span>
</button>
<h4
className="modal-title"
id="ImportMusicServicePlaylistLabel"
style={{ textAlign: "center" }}
>
Import playlist from SoundCloud
</h4>
</div>
<div className="modal-body">
<p className="text-muted">
Add one or more of your SoundCloud playlists below:
</p>
<div
className="list-group"
style={{ maxHeight: "50vh", overflow: "scroll" }}
>
{playlists?.map((playlist: SoundCloudPlaylistObject) => (
<button
type="button"
key={playlist.id}
className="list-group-item"
style={{
display: "flex",
justifyContent: "space-between",
}}
disabled={!!playlistLoading}
name={playlist.title}
onClick={() =>
importTracksToPlaylist(playlist.id, playlist.title)
}
>
<span>{playlist.title}</span>
</button>
))}
</div>
{!!playlistLoading && (
<div>
<p>
Loading playlist {playlistLoading}... It might take some time
</p>
<Loader isLoading={!!playlistLoading} />
</div>
)}
</div>

<div className="modal-footer">
<button
type="button"
className="btn btn-default"
data-dismiss="modal"
onClick={resolveAndClose}
>
Close
</button>
</div>
</div>
</div>
</div>
);
});
Loading
Loading