diff --git a/consul_config.py.ctmpl b/consul_config.py.ctmpl index 488b6b6bbf..3b90b7c6b6 100644 --- a/consul_config.py.ctmpl +++ b/consul_config.py.ctmpl @@ -248,7 +248,6 @@ SPOTIFY_CACHE_CLIENT_SECRET = '''{{template "KEY" "spotify_cache/client_secret"} APPLE_MUSIC_TEAM_ID = '''{{template "KEY" "apple/team_id"}}''' APPLE_MUSIC_KID = '''{{template "KEY" "apple/kid"}}''' APPLE_MUSIC_KEY = '''{{template "KEY" "apple/key"}}''' -APPLE_MUSIC_CALLBACK_URL = '''{{template "KEY" "apple/redirect_uri"}}''' # CRITIQUEBRAINZ CRITIQUEBRAINZ_CLIENT_ID = '''{{template "KEY" "critiquebrainz/client_id"}}''' diff --git a/frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx b/frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx new file mode 100644 index 0000000000..e74c26c39f --- /dev/null +++ b/frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx @@ -0,0 +1,418 @@ +import * as React from "react"; +import { get as _get, isString } from "lodash"; +import { faApple } from "@fortawesome/free-brands-svg-icons"; +import { + getArtistName, + getTrackName, + loadScriptAsync, +} from "../../utils/utils"; +import { DataSourceProps, DataSourceType } from "./BrainzPlayer"; + +export type AppleMusicPlayerProps = DataSourceProps & { + appleMusicUser?: AppleMusicUser; +}; + +export type AppleMusicPlayerState = { + currentAppleMusicTrack?: MusicKit.MediaItem; + progressMs: number; + durationMs: number; +}; +export async function loadAppleMusicKit(): Promise { + if (!window.MusicKit) { + loadScriptAsync( + document, + "https://js-cdn.music.apple.com/musickit/v3/musickit.js" + ); + return new Promise((resolve) => { + window.addEventListener("musickitloaded", () => { + resolve(); + }); + }); + } + return Promise.resolve(); +} +export async function setupAppleMusicKit(developerToken?: string) { + const { MusicKit } = window; + if (!MusicKit) { + throw new Error("Could not load Apple's MusicKit library"); + } + if (!developerToken) { + throw new Error( + "Cannot configure Apple MusikKit without a valid developer token" + ); + } + await MusicKit.configure({ + developerToken, + app: { + name: "ListenBrainz", + // TODO: passs the GIT_COMMIT_SHA env variable to the globalprops and add it here as submission_client_version + build: "latest", + icon: "https://listenbrainz.org/static/img/ListenBrainz_logo_no_text.png", + }, + }); + return MusicKit.getInstance(); +} +export async function authorizeWithAppleMusic( + musicKit: MusicKit.MusicKitInstance, + setToken = true +): Promise { + const musicUserToken = await musicKit.authorize(); + if (musicUserToken && setToken) { + try { + // push token to LB server + const request = await fetch("/settings/music-services/apple/set-token/", { + method: "POST", + body: musicUserToken, + }); + if (!request.ok) { + const { error } = await request.json(); + throw error; + } + } catch (error) { + console.debug("Could not set user's Apple Music token:", error); + } + } + return musicUserToken ?? null; +} +export default class AppleMusicPlayer + extends React.Component + implements DataSourceType { + static hasPermissions = (appleMusicUser?: AppleMusicUser) => { + return Boolean(appleMusicUser?.music_user_token); + }; + + static isListenFromThisService = (listen: Listen | JSPFTrack): boolean => { + // Retro-compatibility: listening_from has been deprecated in favor of music_service + const listeningFrom = _get( + listen, + "track_metadata.additional_info.listening_from" + ); + const musicService = _get( + listen, + "track_metadata.additional_info.music_service" + ); + return ( + (isString(listeningFrom) && + listeningFrom.toLowerCase() === "apple_music") || + (isString(musicService) && + musicService.toLowerCase() === "music.apple.com") || + Boolean(AppleMusicPlayer.getURLFromListen(listen)) + ); + }; + + static getURLFromListen(listen: Listen | JSPFTrack): string | undefined { + return _get(listen, "track_metadata.additional_info.apple_music_id"); + } + + public name = "Apple Music"; + public domainName = "music.apple.com"; + public icon = faApple; + + appleMusicPlayer?: AppleMusicPlayerType; + + constructor(props: AppleMusicPlayerProps) { + super(props); + this.state = { + durationMs: 0, + progressMs: 0, + }; + + // Do an initial check of whether the user wants to link Apple Music before loading the SDK library + if (AppleMusicPlayer.hasPermissions(props.appleMusicUser)) { + loadAppleMusicKit().then(() => { + this.connectAppleMusicPlayer(); + }); + } else { + this.handleAccountError(); + } + } + + componentDidUpdate(prevProps: DataSourceProps) { + const { show } = this.props; + if (prevProps.show && !show) { + this.stopAndClear(); + } + } + + componentWillUnmount(): void { + this.disconnectAppleMusicPlayer(); + } + + playAppleMusicId = async ( + appleMusicId: string, + retryCount = 0 + ): Promise => { + const { handleError, onTrackNotFound } = this.props; + if (retryCount > 5) { + handleError("Could not play AppleMusic track", "Playback error"); + return; + } + if (!this.appleMusicPlayer || !this.appleMusicPlayer?.isAuthorized) { + await this.connectAppleMusicPlayer(); + await this.playAppleMusicId(appleMusicId, retryCount); + return; + } + try { + await this.appleMusicPlayer.setQueue({ + song: appleMusicId, + startPlaying: true, + }); + } catch (error) { + handleError(error.message, "Error playing on Apple Music"); + onTrackNotFound(); + } + }; + + canSearchAndPlayTracks = (): boolean => { + const { appleMusicUser } = this.props; + return AppleMusicPlayer.hasPermissions(appleMusicUser); + }; + + searchAndPlayTrack = async (listen: Listen | JSPFTrack): Promise => { + if (!this.appleMusicPlayer) { + await this.connectAppleMusicPlayer(); + await this.searchAndPlayTrack(listen); + return; + } + const { onTrackNotFound } = this.props; + const trackName = getTrackName(listen); + const artistName = getArtistName(listen); + const releaseName = _get(listen, "track_metadata.release_name"); + const searchTerm = `${trackName} ${artistName} ${releaseName}`; + if (!searchTerm) { + onTrackNotFound(); + return; + } + try { + const response = await this.appleMusicPlayer.api.music( + `/v1/catalog/{{storefrontId}}/search`, + { term: searchTerm, types: "songs" } + ); + const apple_music_id = response?.data?.results?.songs?.data?.[0]?.id; + if (apple_music_id) { + await this.playAppleMusicId(apple_music_id); + return; + } + } catch (error) { + console.debug("Apple Music API request failed:", error); + } + onTrackNotFound(); + }; + + datasourceRecordsListens = (): boolean => { + return false; + }; + + playListen = async (listen: Listen | JSPFTrack): Promise => { + const { show } = this.props; + if (!show) { + return; + } + const apple_music_id = AppleMusicPlayer.getURLFromListen(listen as Listen); + if (apple_music_id) { + await this.playAppleMusicId(apple_music_id); + return; + } + await this.searchAndPlayTrack(listen); + }; + + togglePlay = (): void => { + if ( + this.appleMusicPlayer?.playbackState === + MusicKit.PlaybackStates.playing || + this.appleMusicPlayer?.playbackState === MusicKit.PlaybackStates.loading + ) { + this.appleMusicPlayer?.pause(); + } else { + this.appleMusicPlayer?.play(); + } + }; + + stopAndClear = (): void => { + this.setState({ currentAppleMusicTrack: undefined }); + if (this.appleMusicPlayer) { + this.appleMusicPlayer.pause(); + } + }; + + handleAccountError = (): void => { + const errorMessage = ( +

+ In order to play music with AppleMusic, you will need a AppleMusic + Premium account linked to your ListenBrainz account. +
+ Please try to{" "} + + link for "playing music" feature + {" "} + and refresh this page +

+ ); + const { onInvalidateDataSource } = this.props; + onInvalidateDataSource(this, errorMessage); + }; + + seekToPositionMs = (msTimecode: number): void => { + const timeCode = Math.floor(msTimecode / 1000); + this.appleMusicPlayer?.seekToTime(timeCode); + }; + + disconnectAppleMusicPlayer = (): void => { + if (!this.appleMusicPlayer) { + return; + } + this.appleMusicPlayer?.removeEventListener( + "playbackStateDidChange", + this.onPlaybackStateChange.bind(this) + ); + this.appleMusicPlayer.removeEventListener( + "playbackTimeDidChange", + this.onPlaybackTimeChange.bind(this) + ); + this.appleMusicPlayer.removeEventListener( + "playbackDurationDidChange", + this.onPlaybackDurationChange.bind(this) + ); + this.appleMusicPlayer.removeEventListener( + "nowPlayingItemDidChange", + this.onNowPlayingItemChange.bind(this) + ); + this.appleMusicPlayer = undefined; + }; + + connectAppleMusicPlayer = async (retryCount = 0): Promise => { + this.disconnectAppleMusicPlayer(); + const { appleMusicUser } = this.props; + try { + this.appleMusicPlayer = await setupAppleMusicKit( + appleMusicUser?.developer_token + ); + } catch (error) { + console.debug(error); + if (retryCount >= 5) { + const { onInvalidateDataSource } = this.props; + onInvalidateDataSource( + this, + "Could not load Apple's MusicKit library after 5 retries" + ); + return; + } + setTimeout(this.connectAppleMusicPlayer.bind(this, retryCount + 1), 1000); + return; + } + try { + const userToken = await authorizeWithAppleMusic(this.appleMusicPlayer); + if (userToken === null) { + throw new Error("Could not retrieve Apple Music authorization token"); + } + // this.appleMusicPlayer.musicUserToken = userToken; + } catch (error) { + console.debug(error); + this.handleAccountError(); + } + + this.appleMusicPlayer.addEventListener( + "playbackStateDidChange", + this.onPlaybackStateChange.bind(this) + ); + this.appleMusicPlayer.addEventListener( + "playbackTimeDidChange", + this.onPlaybackTimeChange.bind(this) + ); + this.appleMusicPlayer.addEventListener( + "playbackDurationDidChange", + this.onPlaybackDurationChange.bind(this) + ); + this.appleMusicPlayer.addEventListener( + "nowPlayingItemDidChange", + this.onNowPlayingItemChange.bind(this) + ); + }; + + onPlaybackStateChange = ({ + state: currentState, + }: MusicKit.PlayerPlaybackState) => { + const { onPlayerPausedChange, onTrackEnd } = this.props; + if (currentState === MusicKit.PlaybackStates.playing) { + onPlayerPausedChange(false); + } + if (currentState === MusicKit.PlaybackStates.paused) { + onPlayerPausedChange(true); + } + if (currentState === MusicKit.PlaybackStates.completed) { + onTrackEnd(); + } + }; + + onPlaybackTimeChange = ({ + currentPlaybackTime, + }: MusicKit.PlayerPlaybackTime) => { + const { onProgressChange } = this.props; + const { progressMs } = this.state; + const currentPlaybackTimeMs = currentPlaybackTime * 1000; + if (progressMs !== currentPlaybackTimeMs) { + this.setState({ progressMs: currentPlaybackTimeMs }); + onProgressChange(currentPlaybackTimeMs); + } + }; + + onPlaybackDurationChange = ({ duration }: MusicKit.PlayerDurationTime) => { + const { onDurationChange } = this.props; + const { durationMs } = this.state; + const currentDurationMs = duration * 1000; + if (durationMs !== currentDurationMs) { + this.setState({ durationMs: currentDurationMs }); + onDurationChange(currentDurationMs); + } + }; + + onNowPlayingItemChange = ({ item }: MusicKit.NowPlayingItem) => { + if (!item) { + return; + } + const { onTrackInfoChange } = this.props; + const { name, artistName, albumName, url, artwork } = item.attributes; + let mediaImages: Array | undefined; + if (artwork) { + mediaImages = [ + { + src: artwork.url + .replace("{w}", artwork.width) + .replace("{h}", artwork.height), + sizes: `${artwork.width}x${artwork.height}`, + }, + ]; + } + onTrackInfoChange(name, url, artistName, albumName, mediaImages); + this.setState({ currentAppleMusicTrack: item }); + }; + + getAlbumArt = (): JSX.Element | null => { + const { currentAppleMusicTrack } = this.state; + if ( + !currentAppleMusicTrack || + !currentAppleMusicTrack.attributes || + !currentAppleMusicTrack.attributes.artwork + ) { + return null; + } + const { artwork } = currentAppleMusicTrack.attributes; + return ( + coverart + ); + }; + + render() { + const { show } = this.props; + if (!show) { + return null; + } + return
{this.getAlbumArt()}
; + } +} diff --git a/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx b/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx index 62819587fe..2b58ab7b85 100644 --- a/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx +++ b/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx @@ -29,6 +29,7 @@ import BrainzPlayerUI from "./BrainzPlayerUI"; import SoundcloudPlayer from "./SoundcloudPlayer"; import SpotifyPlayer from "./SpotifyPlayer"; import YoutubePlayer from "./YoutubePlayer"; +import AppleMusicPlayer from "./AppleMusicPlayer"; export type DataSourceType = { name: string; @@ -40,7 +41,11 @@ export type DataSourceType = { datasourceRecordsListens: () => boolean; }; -export type DataSourceTypes = SpotifyPlayer | YoutubePlayer | SoundcloudPlayer; +export type DataSourceTypes = + | SpotifyPlayer + | YoutubePlayer + | SoundcloudPlayer + | AppleMusicPlayer; export type DataSourceProps = { show: boolean; @@ -112,6 +117,9 @@ function isListenFromDatasource( if (datasource instanceof SoundcloudPlayer) { return SoundcloudPlayer.isListenFromThisService(listen); } + if (datasource instanceof AppleMusicPlayer) { + return AppleMusicPlayer.isListenFromThisService(listen); + } return undefined; } @@ -125,6 +133,7 @@ export default class BrainzPlayer extends React.Component< spotifyPlayer: React.RefObject; youtubePlayer: React.RefObject; soundcloudPlayer: React.RefObject; + appleMusicPlayer: React.RefObject; dataSources: Array> = []; playerStateTimerID?: NodeJS.Timeout; @@ -148,6 +157,7 @@ export default class BrainzPlayer extends React.Component< this.spotifyPlayer = React.createRef(); this.youtubePlayer = React.createRef(); this.soundcloudPlayer = React.createRef(); + this.appleMusicPlayer = React.createRef(); this.state = { currentDataSourceIndex: 0, @@ -174,7 +184,12 @@ export default class BrainzPlayer extends React.Component< window.addEventListener("storage", this.onLocalStorageEvent); window.addEventListener("message", this.receiveBrainzPlayerMessage); window.addEventListener("beforeunload", this.alertBeforeClosingPage); - const { spotifyAuth, soundcloudAuth, userPreferences } = this.context; + const { + spotifyAuth, + soundcloudAuth, + appleAuth, + userPreferences, + } = this.context; if ( userPreferences?.brainzplayer?.spotifyEnabled !== false && @@ -182,6 +197,12 @@ export default class BrainzPlayer extends React.Component< ) { this.dataSources.push(this.spotifyPlayer); } + if ( + userPreferences?.brainzplayer?.appleMusicEnabled !== false && + AppleMusicPlayer.hasPermissions(appleAuth) + ) { + this.dataSources.push(this.appleMusicPlayer); + } if ( userPreferences?.brainzplayer?.soundcloudEnabled !== false && SoundcloudPlayer.hasPermissions(soundcloudAuth) @@ -227,7 +248,8 @@ export default class BrainzPlayer extends React.Component< const brainzPlayerDisabled = userPreferences?.brainzplayer?.spotifyEnabled === false && userPreferences?.brainzplayer?.youtubeEnabled === false && - userPreferences?.brainzplayer?.soundcloudEnabled === false; + userPreferences?.brainzplayer?.soundcloudEnabled === false && + userPreferences?.brainzplayer?.appleMusicEnabled === false; if (brainzPlayerDisabled) { toast.info( )} + {userPreferences?.brainzplayer?.appleMusicEnabled !== false && ( + + )} ); diff --git a/frontend/js/src/settings/brainzplayer/BrainzPlayerSettings.tsx b/frontend/js/src/settings/brainzplayer/BrainzPlayerSettings.tsx index d410a533d1..33a9842674 100644 --- a/frontend/js/src/settings/brainzplayer/BrainzPlayerSettings.tsx +++ b/frontend/js/src/settings/brainzplayer/BrainzPlayerSettings.tsx @@ -15,11 +15,13 @@ import GlobalAppContext from "../../utils/GlobalAppContext"; import SpotifyPlayer from "../../common/brainzplayer/SpotifyPlayer"; import SoundcloudPlayer from "../../common/brainzplayer/SoundcloudPlayer"; import { ToastMsg } from "../../notifications/Notifications"; +import AppleMusicPlayer from "../../common/brainzplayer/AppleMusicPlayer"; function BrainzPlayerSettings() { const { spotifyAuth, soundcloudAuth, + appleAuth, APIService, currentUser, } = React.useContext(GlobalAppContext); @@ -35,6 +37,10 @@ function BrainzPlayerSettings() { userPreferences?.brainzplayer?.soundcloudEnabled ?? SoundcloudPlayer.hasPermissions(soundcloudAuth) ); + const [appleMusicEnabled, setAppleMusicEnabled] = React.useState( + userPreferences?.brainzplayer?.appleMusicEnabled ?? + AppleMusicPlayer.hasPermissions(appleAuth) + ); const saveSettings = React.useCallback(async () => { if (!currentUser?.auth_token) { @@ -58,6 +64,7 @@ function BrainzPlayerSettings() { youtubeEnabled, spotifyEnabled, soundcloudEnabled, + appleMusicEnabled, }; } } catch (error) { @@ -78,6 +85,7 @@ function BrainzPlayerSettings() { youtubeEnabled, spotifyEnabled, soundcloudEnabled, + appleMusicEnabled, APIService, currentUser?.auth_token, userPreferences, @@ -136,16 +144,17 @@ function BrainzPlayerSettings() { . - {/*
+
setAppleMusicEnabled(!appleMusicEnabled)} switchLabel={ } /> -
+
Apple Music requires a premium account to play full songs on third-party websites. To sign in, please go to the{" "} - "connect services" page. You - will need to sign in every 6 months. + + "connect services" page + + . You will need to sign in every 6 months as the authorization + expires. -
*/} +
{ + try { + await loadAppleMusicKit(); + const musicKitInstance = await setupAppleMusicKit( + appleAuth?.developer_token + ); + // Delete or recreate the user in the database for this external service + const response = await fetch( + `/settings/music-services/apple/disconnect/`, + { + method: "POST", + body: JSON.stringify({ action }), + headers: { + "Content-Type": "application/json", + }, + } + ); + if (action === "disable") { + await musicKitInstance.unauthorize(); + (appleAuth as AppleMusicUser).music_user_token = undefined; + toast.success( + + ); + } else { + // authorizeWithAppleMusic also sends the token to the server + const newToken = await authorizeWithAppleMusic(musicKitInstance); + if (newToken) { + // We know appleAuth is not undefined because we needed the developer_token + // it contains in order to authorize the user successfully + (appleAuth as AppleMusicUser).music_user_token = newToken; + } + toast.success( + + ); + } + + setPermissions((prevState) => ({ + ...prevState, + appleMusic: action, + })); + } catch (error) { + console.debug(error); + toast.error( + + ); + } + }; + return ( <> @@ -255,6 +324,45 @@ export default function MusicServices() {
+
+
+

Apple Music

+
+
+

+ Connect to your Apple Music account to play music on ListenBrainz. +
+ + Note: Full length track playback requires a paid Apple Music + subscription. +
+ You will need to repeat the sign-in process every 6 months. +
+

+
+
+
+ + + +
+
+
+

Youtube

diff --git a/frontend/js/src/settings/music-services/details/components/ExternalServiceButton.tsx b/frontend/js/src/settings/music-services/details/components/ExternalServiceButton.tsx index bfe63cfca9..7c5a865c43 100644 --- a/frontend/js/src/settings/music-services/details/components/ExternalServiceButton.tsx +++ b/frontend/js/src/settings/music-services/details/components/ExternalServiceButton.tsx @@ -1,7 +1,7 @@ import * as React from "react"; type ExternalServiceButtonProps = { - service: "spotify" | "soundcloud" | "critiquebrainz"; + service: "spotify" | "soundcloud" | "critiquebrainz" | "appleMusic"; current: string; value: string; title: string; diff --git a/frontend/js/src/utils/GlobalAppContext.tsx b/frontend/js/src/utils/GlobalAppContext.tsx index fc194c95ac..cc1cae1844 100644 --- a/frontend/js/src/utils/GlobalAppContext.tsx +++ b/frontend/js/src/utils/GlobalAppContext.tsx @@ -10,6 +10,7 @@ export type GlobalAppContextT = { youtubeAuth?: YoutubeUser; soundcloudAuth?: SoundCloudUser; critiquebrainzAuth?: MetaBrainzProjectUser; + appleAuth?: AppleMusicUser; musicbrainzAuth?: MetaBrainzProjectUser & { refreshMBToken: () => Promise; }; @@ -26,6 +27,7 @@ const GlobalAppContext = createContext({ spotifyAuth: {}, youtubeAuth: {}, soundcloudAuth: {}, + appleAuth: {}, critiquebrainzAuth: {}, musicbrainzAuth: { refreshMBToken: async () => { diff --git a/frontend/js/src/utils/musickit.d.ts b/frontend/js/src/utils/musickit.d.ts new file mode 100644 index 0000000000..83dd823420 --- /dev/null +++ b/frontend/js/src/utils/musickit.d.ts @@ -0,0 +1,194 @@ +interface Window { + MusicKit: typeof MusicKit.MusicKitInstance; +} + +declare namespace MusicKit { + /** + * Configure a MusicKit instance. + */ + async function configure( + configuration: Configuration + ): Promise; + /** + * Returns the configured MusicKit instance. + */ + function getInstance(): MusicKitInstance; + + /** + * This class represents the Apple Music API. + */ + interface API { + /** + * Search the catalog using a query. + * + * @param path The path to the Apple Music API endpoint, without a hostname, and including a leading slash / + * @param parameters A query parameters object that is serialized and passed + * @param options An object with additional options to control how requests are made + * directly to the Apple Music API. + */ + music(path: string, parameters?: any, options?: any): Promise; + } + + /** + * An object that represents artwork. + */ + interface Artwork { + bgColor: string; + height: number; + width: number; + textColor1: string; + textColor2: string; + textColor3: string; + textColor4: string; + url: string; + } + + /** + * The playback states of the music player. + */ + enum PlaybackStates { + none, + loading, + playing, + paused, + stopped, + ended, + seeking, + waiting, + stalled, + completed, + } + + /** + * This class represents a single media item. + */ + interface MediaItem { + albumInfo: string; + albumName: string; + artistName: string; + artwork: Artwork; + artworkURL: string; + attributes: any; + contentRating: string; + discNumber: number; + id: string; + info: string; + isExplicitItem: boolean; + isPlayable: boolean; + isPreparedToPlay: boolean; + isrc: string; + playbackDuration: number; + playlistArtworkURL: string; + playlistName: string; + previewURL: string; + releaseDate?: Date; + title: string; + trackNumber: number; + type: any; + } + + interface SetQueueOptions { + album?: string; + items?: MediaItem[] | string[]; + parameters?: QueryParameters; + playlist?: string; + song?: string; + songs?: string[]; + startPosition?: number; + url?: string; + startPlaying?: boolean; + repeatMode?: boolean; + startTime?: number; + } + + /** + * This object provides access to a player instance, and through the player + * instance, access to control playback. + */ + class MusicKitInstance { + /** + * An instance of the MusicKit API. + */ + readonly api: API; + /** + * The developer token to identify yourself as a trusted developer and + * member of the Apple Developer Program. + */ + readonly developerToken: string; + /** + * A Boolean value indicating whether the user has authenticated and + * authorized the application for use. + */ + readonly isAuthorized: boolean; + /** + * A user token used to access personalized Apple Music content. + */ + musicUserToken: string; + /** + * The current storefront for the configured MusicKit instance. + */ + readonly storefrontId: string; + readonly playbackState: PlaybackStates; + /** + * Add an event listener for a MusicKit instance by name. + * + * @param name The name of the event. + * @param callback The callback function to invoke when the event occurs. + */ + addEventListener(name: string, callback: (event: any) => any): void; + /** + * Returns a promise containing a music user token when a user has + * authenticated and authorized the app. + */ + authorize(): Promise; + /** + * Unauthorizes the app for the current user. + */ + unauthorize(): Promise; + + pause(): void; + + play(): Promise; + /** + * Removes an event listener for a MusicKit instance by name. + * + * @param name The name of the event. + * @param callback The callback function to remove. + */ + removeEventListener(name: string, callback: (event: any) => any): void; + /** + * Sets the playback point to a specified time in seconds. + * + * @param time The time in seconds to set as the playback point. + */ + seekToTime(time: number): Promise; + /** + * Sets a music player's playback queue using queue options. + * + * @param options The option used to set the playback queue. + */ + setQueue(options: SetQueueOptions): Promise; + + stop(): void; + } + + declare type NowPlayingItem = { + item?: MusicKit.MediaItem; + }; + + declare type PlayerPlaybackTime = { + currentPlaybackDuration: number; + currentPlaybackTime: number; + currentPlaybackTimeRemaining: number; + }; + + declare type PlayerDurationTime = { + duration: number; + }; + + declare type PlayerPlaybackState = { + oldState: MusicKit.PlaybackStates; + state: MusicKit.PlaybackStates; + nowPlayingItem?: MusicKit.MediaItem; + }; +} diff --git a/frontend/js/src/utils/types.d.ts b/frontend/js/src/utils/types.d.ts index a6828ef934..c4827383b1 100644 --- a/frontend/js/src/utils/types.d.ts +++ b/frontend/js/src/utils/types.d.ts @@ -2,6 +2,8 @@ declare module "react-responsive"; declare module "spotify-web-playback-sdk"; +declare module "musickit-typescript"; + declare module "time-ago"; declare module "debounce-async"; // declaration typescript file doesn't exist for react-datetime-picker/dist/entry.nostyle.js so had to declare a dummy declaration. @@ -88,6 +90,11 @@ declare type SpotifyUser = { permission?: Array; }; +declare type AppleMusicUser = { + developer_token?: string; + music_user_token?: string; +}; + declare type YoutubeUser = { api_key?: string; }; @@ -223,6 +230,8 @@ declare type SoundCloudTrack = { }; }; +declare type AppleMusicPlayerType = MusicKit.MusicKitInstance; + // Expect either a string or an Error or an html Response object declare type BrainzPlayerError = | string diff --git a/frontend/js/src/utils/utils.tsx b/frontend/js/src/utils/utils.tsx index 4fdd3394ca..462b251e12 100644 --- a/frontend/js/src/utils/utils.tsx +++ b/frontend/js/src/utils/utils.tsx @@ -547,6 +547,7 @@ type GlobalAppProps = { soundcloud?: SoundCloudUser; critiquebrainz?: MetaBrainzProjectUser; musicbrainz?: MetaBrainzProjectUser; + appleMusic?: AppleMusicUser; user_preferences?: UserPreferences; }; type GlobalProps = GlobalAppProps & SentryProps; @@ -593,6 +594,7 @@ const getPageProps = async (): Promise<{ soundcloud, critiquebrainz, musicbrainz, + appleMusic, sentry_traces_sample_rate, sentry_dsn, user_preferences, @@ -621,6 +623,7 @@ const getPageProps = async (): Promise<{ youtubeAuth: youtube, soundcloudAuth: soundcloud, critiquebrainzAuth: critiquebrainz, + appleAuth: appleMusic, musicbrainzAuth: { ...musicbrainz, refreshMBToken: async function refreshMBToken() { diff --git a/listenbrainz/config.py.sample b/listenbrainz/config.py.sample index df3f8bd52f..1da5f887fe 100644 --- a/listenbrainz/config.py.sample +++ b/listenbrainz/config.py.sample @@ -176,8 +176,12 @@ CRITIQUEBRAINZ_REDIRECT_URI = 'http://localhost:8100/settings/music-services/cri # APPLE MUSIC APPLE_MUSIC_TEAM_ID = 'needs a non empty default value for tests, change this' APPLE_MUSIC_KID = 'needs a non empty default value for tests, change this' -APPLE_MUSIC_KEY = 'needs a non empty default value for tests, change this' -APPLE_MUSIC_REDIRECT_URI = 'http://localhost:8100/settings/music-services/critiquebrainz/callback/' +# Please note this is a randomly generated private key for use in tests as a valid key is required +APPLE_MUSIC_KEY = """-----BEGIN EC PRIVATE KEY----- +MHcCAQEEILRqPRAVLQp24+C02idpqlpW2AA2cmoYNxJKY8+ITAAAoAoGCCqGSM49 +AwEHoUQDQgAEXnwpW2Tt3ZKPXwwIvLXb04uW3zYlENse7s10aEWsYtd0njkSgUUf +8Bcx2BRwOPdumSr+fKBsJcAYh/9MHhACMg== +-----END EC PRIVATE KEY-----""" # YOUTUBE YOUTUBE_API_KEY = 'change me' diff --git a/listenbrainz/domain/apple.py b/listenbrainz/domain/apple.py index 8a68103ab7..1da0843e42 100644 --- a/listenbrainz/domain/apple.py +++ b/listenbrainz/domain/apple.py @@ -3,25 +3,87 @@ import jwt from flask import current_app +from data.model.external_service import ExternalServiceType +from listenbrainz.db import external_service_oauth +from listenbrainz.domain.external_service import ExternalService +from listenbrainz.webserver import db_conn + DEVELOPER_TOKEN_VALIDITY = timedelta(days=180) -def generate_developer_token(): - """ Generate an Apple Music JWT token for use with Apple Music API """ - key = current_app.config["APPLE_MUSIC_KEY"] - kid = current_app.config["APPLE_MUSIC_KID"] - team_id = current_app.config["APPLE_MUSIC_TEAM_ID"] - - iat = datetime.now() - exp = iat + DEVELOPER_TOKEN_VALIDITY - token = jwt.encode( - { - "iss": team_id, - "iat": int(iat.timestamp()), - "exp": int(exp.timestamp()) - }, - key, - "ES256", - headers={"kid": kid} - ) - return token +class AppleService(ExternalService): + + def __init__(self): + super(AppleService, self).__init__(ExternalServiceType.APPLE) + self.apple_music_key = current_app.config["APPLE_MUSIC_KEY"] + self.apple_music_kid = current_app.config["APPLE_MUSIC_KID"] + self.apple_music_team_id = current_app.config["APPLE_MUSIC_TEAM_ID"] + + def fetch_access_token(self): + """ Generate an Apple Music JWT token for use with Apple Music API. + Returns: + a dict with the following keys + { + 'access_token', + 'expires_at', + } + """ + iat = datetime.now() + exp = iat + DEVELOPER_TOKEN_VALIDITY + + iat = int(iat.timestamp()) + exp = int(exp.timestamp()) + + token = jwt.encode( + {"iss": self.apple_music_team_id, "iat": iat, "exp": exp}, + self.apple_music_key, + "ES256", + headers={"kid": self.apple_music_kid} + ) + return {"access_token": token, "expires_at": exp} + + def add_new_user(self, user_id: int) -> bool: + """ Create a new apple music row to store a user specific developer token + + Args: + user_id: A flask auth `current_user.id` + token: A dict containing jwt encoded token and its expiry time + """ + token = self.fetch_access_token() + access_token = token["access_token"] + # refresh_token = token["music_user_token"] + expires_at = token["expires_at"] + external_service_oauth.save_token( + db_conn=db_conn, + user_id=user_id, service=self.service, access_token=access_token, + refresh_token=None, token_expires_ts=expires_at, record_listens=False, + scopes=[], external_user_id=None + ) + return True + + def revoke_user(self, user_id: int): + """ Delete the user's connection to external service but retain + the last import error message. + + Args: + user_id (int): the ListenBrainz row ID of the user + """ + external_service_oauth.delete_token(user_id, self.service, remove_import_log=False) + + def set_token(self, user_id: int, music_user_token: str): + """ Create a new apple music row to store a user specific developer token + + Args: + user_id: A flask auth `current_user.id` + music_user_token: A string containing the user token returned by MusicKit after authorization + """ + token = self.fetch_access_token() + + access_token = token["access_token"] + expires_at = token["expires_at"] + external_service_oauth.update_token( + db_conn, + user_id=user_id, service=self.service, access_token=access_token, + refresh_token=music_user_token, expires_at=expires_at + ) + return True diff --git a/listenbrainz/metadata_cache/apple/client.py b/listenbrainz/metadata_cache/apple/client.py index d13d068e4d..113c1a7a1a 100644 --- a/listenbrainz/metadata_cache/apple/client.py +++ b/listenbrainz/metadata_cache/apple/client.py @@ -3,13 +3,14 @@ from requests.adapters import HTTPAdapter from urllib3 import Retry -from listenbrainz.domain.apple import generate_developer_token +from listenbrainz.domain.apple import AppleService class Apple: def __init__(self): - self.developer_token = generate_developer_token() + tokens = AppleService().fetch_access_token() + self.developer_token = tokens["access_token"] self.retries = 5 def _get_requests_session(self): @@ -37,5 +38,6 @@ def get(self, url, params=None): return response.json() if response.status_code == 403: - self.developer_token = generate_developer_token() + tokens = AppleService().fetch_access_token() + self.developer_token = tokens["access_token"] response.raise_for_status() diff --git a/listenbrainz/webserver/utils.py b/listenbrainz/webserver/utils.py index 098190f2bb..0e72534665 100644 --- a/listenbrainz/webserver/utils.py +++ b/listenbrainz/webserver/utils.py @@ -7,7 +7,7 @@ from listenbrainz.webserver import db_conn from listenbrainz.webserver.views.views_utils import get_current_spotify_user, get_current_youtube_user, \ - get_current_critiquebrainz_user, get_current_musicbrainz_user, get_current_soundcloud_user + get_current_critiquebrainz_user, get_current_musicbrainz_user, get_current_soundcloud_user, get_current_apple_music_user import listenbrainz.db.user_setting as db_usersetting REJECT_LISTENS_WITHOUT_EMAIL_ERROR = \ @@ -73,6 +73,7 @@ def get_global_props(): "critiquebrainz": get_current_critiquebrainz_user(), "musicbrainz": get_current_musicbrainz_user(), "soundcloud": get_current_soundcloud_user(), + "appleMusic": get_current_apple_music_user(), "sentry_traces_sample_rate": sentry_config.get("traces_sample_rate", 0.0), } diff --git a/listenbrainz/webserver/views/settings.py b/listenbrainz/webserver/views/settings.py index 20ab49191a..6e018c4b63 100644 --- a/listenbrainz/webserver/views/settings.py +++ b/listenbrainz/webserver/views/settings.py @@ -1,3 +1,4 @@ +import json from datetime import datetime import orjson @@ -15,6 +16,7 @@ from listenbrainz.db import listens_importer from listenbrainz.db.missing_musicbrainz_data import get_user_missing_musicbrainz_data from listenbrainz.db.exceptions import DatabaseException +from listenbrainz.domain.apple import AppleService from listenbrainz.domain.critiquebrainz import CritiqueBrainzService, CRITIQUEBRAINZ_SCOPES from listenbrainz.domain.external_service import ExternalService, ExternalServiceInvalidGrantError from listenbrainz.domain.musicbrainz import MusicBrainzService @@ -228,7 +230,7 @@ def delete_listens(): raise APIInternalServerError("Error while deleting listens for user: %s" % current_user.musicbrainz_id) -def _get_service_or_raise_404(name: str, include_mb=False) -> ExternalService: +def _get_service_or_raise_404(name: str, include_mb=False, exclude_apple=False) -> ExternalService: """Returns the music service for the given name and raise 404 if service is not found @@ -243,6 +245,8 @@ def _get_service_or_raise_404(name: str, include_mb=False) -> ExternalService: return CritiqueBrainzService() elif service == ExternalServiceType.SOUNDCLOUD: return SoundCloudService() + elif not exclude_apple and service == ExternalServiceType.APPLE: + return AppleService() elif include_mb and service == ExternalServiceType.MUSICBRAINZ: return MusicBrainzService() except KeyError: @@ -274,24 +278,30 @@ def music_services_details(): soundcloud_user = soundcloud_service.get_user(current_user.id) current_soundcloud_permissions = "listen" if soundcloud_user else "disable" + apple_service = AppleService() + apple_user = apple_service.get_user(current_user.id) + current_apple_permissions = "listen" if apple_user and apple_user["refresh_token"] else "disable" + data = { "current_spotify_permissions": current_spotify_permissions, "current_critiquebrainz_permissions": current_critiquebrainz_permissions, "current_soundcloud_permissions": current_soundcloud_permissions, + "current_apple_permissions": current_apple_permissions, } return jsonify(data) @settings_bp.route('/music-services//callback/') -@profile_bp.route('/music-services//callback/') @login_required def music_services_callback(service_name: str): - service = _get_service_or_raise_404(service_name) + service = _get_service_or_raise_404(service_name, exclude_apple=True) + code = request.args.get('code') if not code: raise BadRequest('missing code') token = service.fetch_access_token(code) + service.add_new_user(current_user.id, token) return redirect(url_for('settings.index', path='music-services/details')) @@ -299,7 +309,7 @@ def music_services_callback(service_name: str): @settings_bp.route('/music-services//refresh/', methods=['POST']) @api_login_required def refresh_service_token(service_name: str): - service = _get_service_or_raise_404(service_name, include_mb=True) + service = _get_service_or_raise_404(service_name, include_mb=True, exclude_apple=True) user = service.get_user(current_user.id) if not user: raise APINotFound("User has not authenticated to %s" % service_name.capitalize()) @@ -350,10 +360,30 @@ def music_services_disconnect(service_name: str): elif service_name == 'critiquebrainz': if action: return jsonify({"url": service.get_authorize_url(CRITIQUEBRAINZ_SCOPES)}) + elif service_name == 'apple': + service.add_new_user(user_id=current_user.id) + return jsonify({"success": True}) raise BadRequest('Invalid action') +@settings_bp.route('/music-services//set-token/', methods=['POST']) +@api_login_required +def music_services_set_token(service_name: str): + if service_name != 'apple': + raise APIInternalServerError("The set-token method not implemented for this service") + + music_user_token = request.data + + if music_user_token is None: + raise BadRequest('Missing user token in request body') + + apple_service = AppleService() + apple_service.set_token(user_id=current_user.id, music_user_token=music_user_token) + + return jsonify({"success": True}) + + @settings_bp.route('/missing-data/', methods=['POST']) @api_login_required def missing_mb_data(): diff --git a/listenbrainz/webserver/views/views_utils.py b/listenbrainz/webserver/views/views_utils.py index 0c8fba56a8..192004febe 100644 --- a/listenbrainz/webserver/views/views_utils.py +++ b/listenbrainz/webserver/views/views_utils.py @@ -1,6 +1,7 @@ from flask import current_app from flask_login import current_user +from listenbrainz.domain.apple import AppleService from listenbrainz.domain.musicbrainz import MusicBrainzService from listenbrainz.domain.spotify import SpotifyService from listenbrainz.domain.critiquebrainz import CritiqueBrainzService @@ -72,3 +73,20 @@ def get_current_soundcloud_user(): return { "access_token": user["access_token"], } + + +def get_current_apple_music_user(): + """Returns the apple music developer_token and the music_user_token for the + current authenticated user. If the user is unauthenticated or has not + linked their apple music account returns a dict with only the developer_token.""" + tokens = AppleService().fetch_access_token() + developer_token = tokens["access_token"] + if not current_user.is_authenticated: + return {"developer_token": developer_token} + user = AppleService().get_user(current_user.id) + if user is None: + return {"developer_token": developer_token} + return { + "developer_token": developer_token, + "music_user_token": user["refresh_token"] + }