diff --git a/frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx b/frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx index 04a0c1c64d..00b7f7408f 100644 --- a/frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx +++ b/frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx @@ -5,6 +5,7 @@ import { Link } from "react-router-dom"; import fuzzysort from "fuzzysort"; import { getArtistName, + getReleaseName, getTrackName, loadScriptAsync, } from "../../utils/utils"; @@ -12,12 +13,26 @@ import { DataSourceProps, DataSourceType } from "./BrainzPlayer"; import GlobalAppContext from "../../utils/GlobalAppContext"; import { dataSourcesInfo } from "../../settings/brainzplayer/BrainzPlayerSettings"; -export type AppleMusicPlayerProps = DataSourceProps; +export type AppleMusicPlayerProps = DataSourceProps & { + handleAlbumMapping: ( + dataSource: keyof MatchedTrack, + releaseName: string, + album: { + trackName: string; + uri: string; + }[] + ) => void; + getAlbumMapping: ( + listen: BrainzPlayerQueueItem, + dataSource: keyof MatchedTrack + ) => string | undefined; +}; export type AppleMusicPlayerState = { currentAppleMusicTrack?: MusicKit.MediaItem; progressMs: number; durationMs: number; + listen?: BrainzPlayerQueueItem; }; export async function loadAppleMusicKit(): Promise { if (!window.MusicKit) { @@ -173,10 +188,15 @@ export default class AppleMusicPlayer return; } try { - await this.appleMusicPlayer.setQueue({ + const queueData = await this.appleMusicPlayer.setQueue({ song: appleMusicId, startPlaying: true, }); + const albumId = queueData.item(0)?.relationships?.albums?.data?.[0]?.id; + + if (albumId) { + this.fetchAlbumTracksAndUpdateMappings(albumId); + } } catch (error) { handleError(error.message, "Error playing on Apple Music"); onTrackNotFound(); @@ -188,7 +208,7 @@ export default class AppleMusicPlayer return AppleMusicPlayer.hasPermissions(appleMusicUser); }; - searchAndPlayTrack = async (listen: Listen | JSPFTrack): Promise => { + searchAndPlayTrack = async (listen: BrainzPlayerQueueItem): Promise => { if (!this.appleMusicPlayer) { await this.connectAppleMusicPlayer(); await this.searchAndPlayTrack(listen); @@ -258,12 +278,14 @@ export default class AppleMusicPlayer return false; }; - playListen = async (listen: Listen | JSPFTrack): Promise => { - const { show } = this.props; + playListen = async (listen: BrainzPlayerQueueItem): Promise => { + const { show, getAlbumMapping } = this.props; if (!show) { return; } - const apple_music_id = AppleMusicPlayer.getURLFromListen(listen as Listen); + this.setState({ listen }); + + const apple_music_id = getAlbumMapping(listen, "appleMusic"); if (apple_music_id) { await this.playAppleMusicId(apple_music_id); return; @@ -448,6 +470,36 @@ export default class AppleMusicPlayer this.setState({ currentAppleMusicTrack: item }); }; + fetchAlbumTracksAndUpdateMappings = async (albumId: string) => { + // Exptract the album id from the url + const { listen } = this.state; + const releaseName = getReleaseName(listen as Listen); + + if (!releaseName || !albumId) { + return; + } + const { handleAlbumMapping } = this.props; + + const response = await this.appleMusicPlayer?.api.music( + `/v1/catalog/{{storefrontId}}/albums/${albumId}` + ); + + // @ts-ignore + const tracks = response?.data?.data?.[0]?.relationships?.tracks + ?.data as MusicKit.Song[]; + + if (!tracks || tracks?.length === 0) { + return; + } + + const trackMappings = tracks.map((track) => ({ + uri: track.id, + trackName: track.attributes?.name, + })); + + handleAlbumMapping("appleMusic", releaseName, trackMappings); + }; + getAlbumArt = (): JSX.Element | null => { const { currentAppleMusicTrack } = this.state; if ( diff --git a/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx b/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx index 8331ccce27..ac9e064405 100644 --- a/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx +++ b/frontend/js/src/common/brainzplayer/BrainzPlayer.tsx @@ -16,6 +16,7 @@ import * as React from "react"; import { toast } from "react-toastify"; import { Link } from "react-router-dom"; import { Helmet } from "react-helmet"; +import Fuse from "fuse.js"; import { ToastMsg, createNotification, @@ -39,11 +40,16 @@ import { useBrainzPlayerContext, useBrainzPlayerDispatch, } from "./BrainzPlayerContext"; +import { + getReleaseMBID, + getReleaseName, + getTrackName, +} from "../../utils/utils"; export type DataSourceType = { name: string; icon: IconProp; - playListen: (listen: Listen | JSPFTrack) => void; + playListen: (listen: BrainzPlayerQueueItem) => void; togglePlay: () => void; seekToPositionMs: (msTimecode: number) => void; canSearchAndPlayTracks: () => boolean; @@ -160,6 +166,7 @@ export default function BrainzPlayer() { queue, ambientQueue, queueRepeatMode, + albumMapping, } = useBrainzPlayerContext(); const dispatch = useBrainzPlayerDispatch(); @@ -246,6 +253,8 @@ export default function BrainzPlayer() { playerPausedRef.current = playerPaused; const continuousPlaybackTimeRef = React.useRef(continuousPlaybackTime); continuousPlaybackTimeRef.current = continuousPlaybackTime; + const albumMappingRef = React.useRef(albumMapping); + albumMappingRef.current = albumMapping; // Functions const alertBeforeClosingPage = (event: BeforeUnloadEvent) => { @@ -945,6 +954,114 @@ export default function BrainzPlayer() { } }; + /** + * Stores mappings between tracks from different music services for the same album. + * This allows the player to find equivalent tracks across different services. + * + * @param dataSource - The music service ('spotify', 'appleMusic', 'youtube') + * @param releaseName - The name of the album/release + * @param album - Array of tracks in the album, each with a name and service-specific URI + */ + const handleAlbumMapping = React.useCallback( + ( + dataSource: keyof MatchedTrack, + releaseName: string, + album: { + trackName: string; + uri: string; + }[] + ): void => { + dispatch({ + type: "ADD_ALBUM_MAPPING", + data: { + dataSource, + releaseName, + album, + }, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + /** + * Retrieves the URI for a specific track from a given music service. + * This function uses fuzzy matching to find the closest match for the release and track names. + * + * @param listen - The listen object containing the release name and track name + * @param dataSource - The music service (e.g., 'spotify', 'appleMusic') + * @returns The URI for the track, or undefined if no match is found + */ + const getAlbumMapping = React.useCallback( + ( + listen: BrainzPlayerQueueItem, + dataSource: keyof MatchedTrack + ): string | undefined => { + const releaseName = getReleaseName(listen); + const trackName = getTrackName(listen); + const existingReleases = Object.keys(albumMappingRef.current); + if (!existingReleases.length) { + return undefined; + } + + // First, find matching release + const releaseOptions = { + includeScore: true, + threshold: 0.3, + keys: ["releaseName"], + }; + + const releaseFuse = new Fuse( + existingReleases.map((name) => ({ releaseName: name })), + releaseOptions + ); + + const releaseMatches = releaseFuse.search(releaseName); + + // If we find a matching release + if ( + releaseMatches.length > 0 && + releaseMatches[0].score && + releaseMatches[0].score < 0.3 + ) { + const matchedReleaseName = releaseMatches[0].item.releaseName; + const tracksInRelease = Object.keys( + albumMappingRef.current[matchedReleaseName] + ); + + // Then, find matching track within that release + const trackOptions = { + threshold: 0.3, + keys: ["trackName"], + }; + + const trackFuse = new Fuse( + tracksInRelease.map((name) => ({ trackName: name })), + trackOptions + ); + + const trackMatches = trackFuse.search(trackName); + + // If we find a matching track, return its URI for the requested data source + if ( + trackMatches.length > 0 && + trackMatches[0].score && + trackMatches[0].score < 0.3 + ) { + const matchedTrackName = trackMatches[0].item.trackName; + + return albumMappingRef.current[matchedReleaseName][matchedTrackName][ + dataSource + ]; + } + } + + return undefined; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + React.useEffect(() => { window.addEventListener("storage", onLocalStorageEvent); window.addEventListener("message", receiveBrainzPlayerMessage); @@ -1029,6 +1146,8 @@ export default function BrainzPlayer() { handleError={handleError} handleWarning={handleWarning} handleSuccess={handleSuccess} + handleAlbumMapping={handleAlbumMapping} + getAlbumMapping={getAlbumMapping} /> )} {userPreferences?.brainzplayer?.youtubeEnabled !== false && ( @@ -1095,6 +1214,8 @@ export default function BrainzPlayer() { handleError={handleError} handleWarning={handleWarning} handleSuccess={handleSuccess} + handleAlbumMapping={handleAlbumMapping} + getAlbumMapping={getAlbumMapping} /> )} diff --git a/frontend/js/src/common/brainzplayer/BrainzPlayerContext.tsx b/frontend/js/src/common/brainzplayer/BrainzPlayerContext.tsx index 1cac993697..3439409781 100644 --- a/frontend/js/src/common/brainzplayer/BrainzPlayerContext.tsx +++ b/frontend/js/src/common/brainzplayer/BrainzPlayerContext.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { faRepeat } from "@fortawesome/free-solid-svg-icons"; import { isEqual, isNil } from "lodash"; +import Fuse from "fuse.js"; import { faRepeatOnce } from "../../utils/icons"; import { listenOrJSPFTrackToQueueItem } from "./utils"; @@ -48,6 +49,7 @@ export type BrainzPlayerContextT = { queue: BrainzPlayerQueue; ambientQueue: BrainzPlayerQueue; queueRepeatMode: QueueRepeatMode; + albumMapping: AlbumMapping; }; export const initialValue: BrainzPlayerContextT = { @@ -65,6 +67,7 @@ export const initialValue: BrainzPlayerContextT = { queue: [], ambientQueue: [], queueRepeatMode: QueueRepeatModes.off, + albumMapping: {}, }; export type BrainzPlayerActionType = Partial & { @@ -81,7 +84,8 @@ export type BrainzPlayerActionType = Partial & { | "ADD_LISTEN_TO_TOP_OF_QUEUE" | "ADD_LISTEN_TO_BOTTOM_OF_QUEUE" | "ADD_LISTEN_TO_BOTTOM_OF_AMBIENT_QUEUE" - | "ADD_MULTIPLE_LISTEN_TO_BOTTOM_OF_AMBIENT_QUEUE"; + | "ADD_MULTIPLE_LISTEN_TO_BOTTOM_OF_AMBIENT_QUEUE" + | "ADD_ALBUM_MAPPING"; data?: any; }; @@ -317,6 +321,58 @@ function valueReducer( ambientQueue: [...ambientQueue, ...tracksToAdd], }; } + case "ADD_ALBUM_MAPPING": { + const { dataSource, releaseName, album } = action.data as { + dataSource: keyof MatchedTrack; + releaseName: string; + album: { + trackName: string; + uri: string; + }[]; + }; + const { albumMapping } = state; + + // Initialize Fuse for fuzzy matching release names + const fuseOptions = { + threshold: 0.3, + distance: 100, + keys: ["releaseName"], + }; + + const existingReleases = Object.keys(albumMapping).map((key) => ({ + releaseName: key, + })); + + const fuse = new Fuse(existingReleases, fuseOptions); + const matches = fuse.search(releaseName); + + let targetReleaseName = releaseName; + // If we find a close match, use that release name instead + if (matches.length > 0 && matches[0].score && matches[0].score < 0.3) { + targetReleaseName = matches[0].item.releaseName; + } + + // Create or update the release mapping + const updatedReleaseMapping = { + ...(albumMapping[targetReleaseName] || {}), + }; + + // Add each track's URI under the appropriate dataSource + album.forEach(({ trackName, uri }) => { + updatedReleaseMapping[trackName] = { + ...(updatedReleaseMapping[trackName] || {}), + [dataSource]: uri, + }; + }); + + return { + ...state, + albumMapping: { + ...albumMapping, + [targetReleaseName]: updatedReleaseMapping, + }, + }; + } default: { throw Error(`Unknown action: ${action.type}`); } diff --git a/frontend/js/src/common/brainzplayer/SpotifyPlayer.tsx b/frontend/js/src/common/brainzplayer/SpotifyPlayer.tsx index 32802ef04a..9c513cef80 100644 --- a/frontend/js/src/common/brainzplayer/SpotifyPlayer.tsx +++ b/frontend/js/src/common/brainzplayer/SpotifyPlayer.tsx @@ -16,6 +16,7 @@ import { loadScriptAsync, getTrackName, getArtistName, + getReleaseName, } from "../../utils/utils"; import { DataSourceType, DataSourceProps } from "./BrainzPlayer"; import GlobalAppContext from "../../utils/GlobalAppContext"; @@ -37,6 +38,18 @@ const fixSpotifyPlayerStyleIssue = () => { export type SpotifyPlayerProps = DataSourceProps & { refreshSpotifyToken: () => Promise; + handleAlbumMapping: ( + dataSource: keyof MatchedTrack, + releaseName: string, + album: { + trackName: string; + uri: string; + }[] + ) => void; + getAlbumMapping: ( + listen: BrainzPlayerQueueItem, + dataSource: keyof MatchedTrack + ) => string | undefined; }; export type SpotifyPlayerState = { @@ -44,6 +57,7 @@ export type SpotifyPlayerState = { durationMs: number; trackWindow?: SpotifyPlayerTrackWindow; device_id?: string; + listen?: BrainzPlayerQueueItem; }; export default class SpotifyPlayer @@ -172,7 +186,7 @@ export default class SpotifyPlayer return `spotify:track:${spotifyTrack}`; } - searchAndPlayTrack = async (listen: Listen | JSPFTrack): Promise => { + searchAndPlayTrack = async (listen: BrainzPlayerQueueItem): Promise => { const trackName = getTrackName(listen); // use only the first artist without feat. artists as it can confuse Spotify search const artistName = getArtistName(listen, true); @@ -256,6 +270,7 @@ export default class SpotifyPlayer ); let errorObject; if (response.ok) { + this.fetchAlbumTracksAndUpdateMappings(spotifyURI); return; } try { @@ -315,15 +330,16 @@ export default class SpotifyPlayer ); }; - playListen = (listen: Listen | JSPFTrack): void => { - const { show } = this.props; + playListen = (listen: BrainzPlayerQueueItem): void => { + const { show, getAlbumMapping } = this.props; if (!show) { return; } - if (SpotifyPlayer.getURLFromListen(listen)) { - this.playSpotifyURI( - SpotifyPlayer.getSpotifyUriFromListen(listen as Listen) - ); + this.setState({ listen }); + const spotifyUri = getAlbumMapping(listen, "spotify"); + + if (spotifyUri) { + this.playSpotifyURI(spotifyUri); } else { this.searchAndPlayTrack(listen); } @@ -504,6 +520,77 @@ export default class SpotifyPlayer }); }; + getAlbumIDFromTrackID = async ( + trackId: string + ): Promise => { + const response = await fetch( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.accessToken}`, + }, + } + ); + + if (!response.ok) { + return undefined; + } + const data = await response.json(); + return data.album.id; + }; + + fetchAlbumTracksAndUpdateMappings = async (trackURI: string) => { + const { handleAlbumMapping } = this.props; + const { listen } = this.state; + const releaseName = getReleaseName(listen as Listen); + if (!releaseName || !trackURI) { + return; + } + + const trackId = trackURI.split(":")[2]; + const albumID = await this.getAlbumIDFromTrackID(trackId); + if (!albumID) { + return; + } + + const tracks: SpotifyTrack[] = []; + + // Fetch tracks from Spotify API + const fetchTracks = async (offset: number = 0) => { + const response = await fetch( + `https://api.spotify.com/v1/albums/${albumID}/tracks?limit=50&offset=${offset}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.accessToken}`, + }, + } + ); + + if (!response.ok) { + return; + } + const data = await response.json(); + tracks.push(...data.items); + if (data.next) { + await fetchTracks(offset + 50); + } + }; + await fetchTracks(); + + if (!tracks || tracks.length === 0) { + return; + } + + const trackMappings = tracks.map((track) => ({ + uri: track.uri, + trackName: track.name, + })); + + handleAlbumMapping("spotify", releaseName, trackMappings); + }; + handlePlayerStateChanged = (playerState: SpotifyPlayerSDKState): void => { const { show } = this.props; if (!playerState || !show) { diff --git a/frontend/js/src/common/brainzplayer/utils.ts b/frontend/js/src/common/brainzplayer/utils.ts index 21f77b2377..fdb0a9d13d 100644 --- a/frontend/js/src/common/brainzplayer/utils.ts +++ b/frontend/js/src/common/brainzplayer/utils.ts @@ -10,6 +10,9 @@ import { getReleaseName, getTrackName, } from "../../utils/utils"; +import SpotifyPlayer from "./SpotifyPlayer"; +import YoutubePlayer from "./YoutubePlayer"; +import AppleMusicPlayer from "./AppleMusicPlayer"; const getBrainzPlayerQueueItemKey = (listen: Listen): string => `${getRecordingMSID(listen)}-${getTrackName(listen)}-${getArtistName( @@ -24,6 +27,17 @@ const getBrainzPlayerQueueItemKey = (listen: Listen): string => listen.listened_at }-${listen.inserted_at}`; +const getMatchedTrack = (listen: Listen): MatchedTrack => { + const spotifyURI = SpotifyPlayer.getSpotifyUriFromListen(listen); + const youtubeURI = YoutubePlayer.getVideoIDFromListen(listen); + const appleMusicURI = AppleMusicPlayer.getURLFromListen(listen); + return { + spotify: spotifyURI ?? undefined, + youtube: youtubeURI ?? undefined, + appleMusic: appleMusicURI ?? undefined, + }; +}; + // eslint-disable-next-line import/prefer-default-export export function listenOrJSPFTrackToQueueItem( track: Listen | JSPFTrack @@ -34,9 +48,11 @@ export function listenOrJSPFTrackToQueueItem( } else { listenTrack = cloneDeep(track as BrainzPlayerQueueItem); } + const matchedTrack = getMatchedTrack(listenTrack); const queueItem = { ...listenTrack, id: `queue-item-${getBrainzPlayerQueueItemKey(listenTrack)}`, + matchedTrack, }; return queueItem; } diff --git a/frontend/js/src/utils/musickit.d.ts b/frontend/js/src/utils/musickit.d.ts index ba1ef9e7a2..71693b66ec 100644 --- a/frontend/js/src/utils/musickit.d.ts +++ b/frontend/js/src/utils/musickit.d.ts @@ -141,6 +141,22 @@ declare namespace MusicKit { title: string; trackNumber: number; type: any; + relationships?: { + albums?: { + data?: { + id: string; + type: string; + href: string; + }[]; + }; + artists?: { + data?: { + id: string; + type: string; + href: string; + }[]; + }; + }; } interface SetQueueOptions { @@ -225,7 +241,7 @@ declare namespace MusicKit { * * @param options The option used to set the playback queue. */ - setQueue(options: SetQueueOptions): Promise; + setQueue(options: SetQueueOptions): Promise; stop(): void; } @@ -249,4 +265,67 @@ declare namespace MusicKit { state: MusicKit.PlaybackStates; nowPlayingItem?: MusicKit.MediaItem; }; + + interface Queue { + /** + * A Boolean value indicating whether the queue has no items. + */ + readonly isEmpty: boolean; + /** + * An array of all the media items in the queue. + */ + readonly items: MediaItem[]; + /** + * The number of items in the queue. + */ + readonly length: number; + /** + * The next playable media item in the queue. + */ + readonly nextPlayableItem?: MediaItem; + /** + * The current queue position. + */ + readonly position: number; + /** + * The previous playable media item in the queue. + */ + readonly previousPlayableItem?: MediaItem; + + /** + * Add an event listener for a MusicKit queue by name. + * + * @param name The name of the event. + * @param callback The callback function to remove. + */ + addEventListener(name: string, callback: () => any): void; + /** + * Inserts the media items defined by the queue descriptor after the last + * media item in the current queue. + */ + append(descriptor: descriptor): void; + /** + * Returns the index in the playback queue for a media item descriptor. + * + * @param descriptor A descriptor can be an instance of the MusicKit.MediaItem + * class, or a string identifier. + */ + indexForItem(descriptor: descriptor): number; + /** + * Returns the media item located in the indicated array index. + */ + item(index: number): MediaItem | null | undefined; + /** + * Inserts the media items defined by the queue descriptor into the current + * queue immediately after the currently playing media item. + */ + prepend(descriptor: any): void; + /** + * Removes an event listener for a MusicKit queue by name. + * + * @param name The name of the event. + * @param callback The callback function to remove. + */ + removeEventListener(name: string, callback: () => any): void; + } } diff --git a/frontend/js/src/utils/types.d.ts b/frontend/js/src/utils/types.d.ts index 20aaad9cd7..ef58a27249 100644 --- a/frontend/js/src/utils/types.d.ts +++ b/frontend/js/src/utils/types.d.ts @@ -667,6 +667,20 @@ declare type FeedbackForUserForRecordingsRequestBody = { recording_msids?: string[]; }; +declare type MatchedTrack = { + spotify?: string; + youtube?: string; + appleMusic?: string; +}; + +type AlbumMapping = Record< + string, // releaseName + Record< + string, // trackName + MatchedTrack + > +>; + declare type BrainzPlayerQueueItem = Listen & { id: string; }; diff --git a/frontend/js/tests/common/brainzplayer/SpotifyPlayer.test.tsx b/frontend/js/tests/common/brainzplayer/SpotifyPlayer.test.tsx index 71d72c143f..22e4ba51f9 100644 --- a/frontend/js/tests/common/brainzplayer/SpotifyPlayer.test.tsx +++ b/frontend/js/tests/common/brainzplayer/SpotifyPlayer.test.tsx @@ -8,6 +8,7 @@ import APIService from "../../../src/utils/APIService"; import { DataSourceTypes } from "../../../src/common/brainzplayer/BrainzPlayer"; import GlobalAppContext from "../../../src/utils/GlobalAppContext"; import RecordingFeedbackManager from "../../../src/utils/RecordingFeedbackManager"; +import { listenOrJSPFTrackToQueueItem } from "../../../src/common/brainzplayer/utils"; // Create a new instance of GlobalAppContext const defaultContext = { @@ -48,6 +49,16 @@ const props = { dataSource?: DataSourceTypes, message?: string | JSX.Element ) => {}, + websocketsUrl: "", + handleAlbumMapping: ( + dataSource: keyof MatchedTrack, + releaseMBID: string, + trackMappings: Array<{ uri: string; trackName: string }> + ) => {}, + getAlbumMapping: ( + listen: BrainzPlayerQueueItem, + dataSource: keyof MatchedTrack + ) => "", }; describe("SpotifyPlayer", () => { @@ -82,6 +93,8 @@ describe("SpotifyPlayer", () => { }, }, }; + + const bpQueueItem = listenOrJSPFTrackToQueueItem(spotifyListen); const wrapper = shallow(); const instance = wrapper.instance(); @@ -89,7 +102,7 @@ describe("SpotifyPlayer", () => { instance.searchAndPlayTrack = jest.fn(); // play listen should extract the spotify track ID await act(() => { - instance.playListen(spotifyListen); + instance.playListen(bpQueueItem); }); expect(instance.playSpotifyURI).toHaveBeenCalledTimes(1); expect(instance.playSpotifyURI).toHaveBeenCalledWith(